Compare commits
52 Commits
222e5bcb9e
...
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 |
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
|
||||
```
|
||||
242
README.md
242
README.md
@@ -103,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)
|
||||
@@ -231,20 +232,36 @@ notes (
|
||||
- [x] API 오류 처리 및 사용자 피드백
|
||||
- [x] 실시간 문서 목록 새로고침
|
||||
|
||||
### Phase 7: 최우선 개선사항 (진행 중) 🔥
|
||||
- [ ] **메모-하이라이트 통합**: 하이라이트 기반 메모 기능 완전 통합
|
||||
- [ ] **노트 뷰어 기능**: 노트에서 하이라이트, 메모, 링크 등 모든 기능 지원
|
||||
- [ ] **API 구조 정리**: 메모(`/api/notes/`) vs 노트(`/api/note-documents/`) 명확한 분리
|
||||
- [ ] **용어 통일**: 전체 시스템에서 메모/노트 용어 일관성 확보
|
||||
### Phase 7: 최우선 개선사항 ✅
|
||||
- [x] **메모-하이라이트 통합**: 하이라이트 기반 메모 기능 완전 통합
|
||||
- [x] **노트 뷰어 기능**: 노트에서 하이라이트, 메모, 링크 등 모든 기능 지원
|
||||
- [x] **API 구조 정리**: 메모(`/api/notes/`) vs 노트(`/api/note-documents/`) 명확한 분리
|
||||
- [x] **용어 통일**: 전체 시스템에서 메모/노트 용어 일관성 확보
|
||||
- [x] **노트북-서적 링크 시스템**: 양방향 링크/백링크 완전 구현
|
||||
|
||||
### Phase 8: 향후 개선사항 (예정)
|
||||
### 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
|
||||
@@ -255,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
|
||||
@@ -285,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 엔드포인트 (예상)
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# Viewer.js 리팩토링 계획
|
||||
|
||||
## 📊 현재 상황
|
||||
- **파일 크기**: 3,712줄 (너무 큼!)
|
||||
- **주요 문제**: 유지보수 어려움, 기능 추가 시 복잡도 증가
|
||||
- **목표**: 모듈화를 통한 코드 분리 및 관리성 향상
|
||||
|
||||
## 🏗️ 분리 구조
|
||||
|
||||
```
|
||||
frontend/static/js/viewer/
|
||||
├── core/
|
||||
│ ├── viewer-core.js # 메인 Alpine.js 컴포넌트
|
||||
│ └── document-loader.js # 문서/노트 로딩
|
||||
├── features/
|
||||
│ ├── highlight-manager.js # 하이라이트/메모 관리
|
||||
│ ├── link-manager.js # 문서링크/백링크 관리
|
||||
│ ├── bookmark-manager.js # 북마크 관리
|
||||
│ └── ui-manager.js # 모달/패널/검색 UI
|
||||
└── utils/
|
||||
├── text-utils.js # 텍스트 선택/조작 유틸
|
||||
└── dom-utils.js # DOM 조작 유틸
|
||||
```
|
||||
|
||||
## 📦 모듈별 책임
|
||||
|
||||
### 1. 📄 DocumentLoader (`core/document-loader.js`)
|
||||
**책임**: 문서/노트 로딩 및 네비게이션
|
||||
- `loadDocument()` - HTML 문서 로딩
|
||||
- `loadNote()` - 노트 문서 로딩
|
||||
- `loadNavigation()` - 네비게이션 정보 로딩
|
||||
- `checkForTextHighlight()` - URL 파라미터 기반 하이라이트
|
||||
|
||||
### 2. 🎨 HighlightManager (`features/highlight-manager.js`)
|
||||
**책임**: 하이라이트 및 메모 관리
|
||||
- `loadHighlights()` - 하이라이트 데이터 로딩
|
||||
- `loadNotes()` - 메모 데이터 로딩
|
||||
- `renderHighlights()` - 하이라이트 렌더링
|
||||
- `createHighlightWithColor()` - 하이라이트 생성
|
||||
- `saveNote()` - 메모 저장
|
||||
- `deleteNote()` - 메모 삭제
|
||||
|
||||
### 3. 🔗 LinkManager (`features/link-manager.js`)
|
||||
**책임**: 문서링크 및 백링크 관리
|
||||
- `loadDocumentLinks()` - 문서링크 로딩
|
||||
- `loadBacklinks()` - 백링크 로딩
|
||||
- `renderDocumentLinks()` - 문서링크 렌더링
|
||||
- `renderBacklinkHighlights()` - 백링크 하이라이트 렌더링
|
||||
- `createLink()` - 링크 생성
|
||||
- `navigateToLink()` - 링크 네비게이션
|
||||
|
||||
### 4. 📌 BookmarkManager (`features/bookmark-manager.js`)
|
||||
**책임**: 북마크 관리
|
||||
- `loadBookmarks()` - 북마크 데이터 로딩
|
||||
- `saveBookmark()` - 북마크 저장
|
||||
- `deleteBookmark()` - 북마크 삭제
|
||||
- `navigateToBookmark()` - 북마크로 이동
|
||||
|
||||
### 5. 🎛️ UIManager (`features/ui-manager.js`)
|
||||
**책임**: 모달, 패널, 검색 UI 관리
|
||||
- `toggleFeatureMenu()` - 기능 메뉴 토글
|
||||
- `showModal()` / `hideModal()` - 모달 관리
|
||||
- `handleSearch()` - 검색 기능
|
||||
- `toggleLanguage()` - 언어 전환
|
||||
- `setupTextSelectionMode()` - 텍스트 선택 모드
|
||||
|
||||
### 6. 🔧 ViewerCore (`core/viewer-core.js`)
|
||||
**책임**: Alpine.js 컴포넌트 및 모듈 통합
|
||||
- Alpine.js 상태 관리
|
||||
- 모듈 간 통신 조정
|
||||
- 초기화 및 라이프사이클 관리
|
||||
- 전역 이벤트 처리
|
||||
|
||||
### 7. 🛠️ Utils
|
||||
**책임**: 공통 유틸리티 함수
|
||||
- `text-utils.js`: 텍스트 선택, 범위 조작
|
||||
- `dom-utils.js`: DOM 조작, 요소 검색
|
||||
|
||||
## 🔄 연결관계도
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[ViewerCore] --> B[DocumentLoader]
|
||||
A --> C[HighlightManager]
|
||||
A --> D[LinkManager]
|
||||
A --> E[BookmarkManager]
|
||||
A --> F[UIManager]
|
||||
|
||||
B --> G[text-utils]
|
||||
C --> G
|
||||
C --> H[dom-utils]
|
||||
D --> G
|
||||
D --> H
|
||||
E --> G
|
||||
F --> H
|
||||
|
||||
C -.-> D[하이라이트↔링크 연동]
|
||||
D -.-> C[백링크↔하이라이트 연동]
|
||||
```
|
||||
|
||||
## 📋 마이그레이션 순서
|
||||
|
||||
1. **📄 DocumentLoader** (가장 독립적)
|
||||
2. **🎨 HighlightManager** (백링크와 연관성 적음)
|
||||
3. **📌 BookmarkManager** (독립적 기능)
|
||||
4. **🎛️ UIManager** (UI 관련 기능)
|
||||
5. **🔗 LinkManager** (백링크 개선 예정이므로 마지막)
|
||||
6. **🔧 ViewerCore** (모든 모듈 통합)
|
||||
|
||||
## 🔍 현재 코드 의존성 분석
|
||||
|
||||
### 핵심 함수들:
|
||||
- **초기화**: `init()`, `loadDocumentData()`
|
||||
- **문서 로딩**: `loadDocument()`, `loadNote()`, `loadNavigation()`
|
||||
- **하이라이트**: `renderHighlights()`, `createHighlight()`, `handleTextSelection()`
|
||||
- **메모**: `saveNote()`, `deleteNote()`, `createMemoForHighlight()`
|
||||
- **북마크**: `addBookmark()`, `saveBookmark()`, `deleteBookmark()`
|
||||
- **링크**: `renderDocumentLinks()`, `renderBacklinkHighlights()`, `loadBacklinks()`
|
||||
- **UI**: `toggleLanguage()`, `searchInDocument()`, `goBack()`
|
||||
|
||||
### 상호 의존성:
|
||||
1. **하이라이트 ↔ 메모**: `createMemoForHighlight()` 연결
|
||||
2. **하이라이트 ↔ 링크**: `renderBacklinkHighlights()` 연결
|
||||
3. **문서로딩 → 모든 기능**: 초기화 후 모든 기능 활성화
|
||||
4. **UI → 모든 기능**: 모달/패널에서 모든 기능 호출
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
- **점진적 마이그레이션**: 한 번에 하나씩 분리
|
||||
- **기능 테스트**: 각 단계마다 기능 동작 확인
|
||||
- **의존성 관리**: 모듈 간 순환 참조 방지
|
||||
- **Alpine.js 호환성**: 기존 템플릿과의 호환성 유지
|
||||
|
||||
## 🎯 기대 효과
|
||||
|
||||
- **유지보수성 향상**: 기능별 코드 분리
|
||||
- **개발 효율성**: 병렬 개발 가능
|
||||
- **테스트 용이성**: 모듈별 단위 테스트
|
||||
- **확장성**: 새 기능 추가 시 영향 범위 최소화
|
||||
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();
|
||||
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)})
|
||||
|
||||
# 마지막 로그인 시간 업데이트
|
||||
|
||||
@@ -33,7 +33,7 @@ async def _get_book_response(db: AsyncSession, book: Book) -> BookResponse:
|
||||
document_count=document_count
|
||||
)
|
||||
|
||||
@router.post("/", response_model=BookResponse, status_code=status.HTTP_201_CREATED)
|
||||
@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),
|
||||
@@ -58,7 +58,7 @@ async def create_book(
|
||||
await db.refresh(new_book)
|
||||
return await _get_book_response(db, new_book)
|
||||
|
||||
@router.get("/", response_model=List[BookResponse])
|
||||
@router.get("", response_model=List[BookResponse])
|
||||
async def get_books(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
|
||||
@@ -64,6 +64,7 @@ class DocumentLinkResponse(BaseModel):
|
||||
# 대상 문서 정보
|
||||
target_document_title: str
|
||||
target_document_book_id: Optional[str]
|
||||
target_content_type: Optional[str] = "document" # "document" 또는 "note"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -115,29 +116,55 @@ async def create_document_link(
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Target document not found"
|
||||
)
|
||||
# 문서에서 찾지 못하면 노트에서 찾기
|
||||
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 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"
|
||||
)
|
||||
# 대상 문서/노트 권한 확인
|
||||
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(
|
||||
@@ -160,7 +187,9 @@ async def create_document_link(
|
||||
await db.commit()
|
||||
await db.refresh(new_link)
|
||||
|
||||
print(f"✅ 링크 생성 완료: {source_doc.title} -> {target_doc.title}")
|
||||
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}")
|
||||
@@ -184,8 +213,8 @@ async def create_document_link(
|
||||
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_doc.title,
|
||||
target_document_book_id=str(target_doc.book_id) if target_doc.book_id 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)
|
||||
)
|
||||
|
||||
|
||||
@@ -213,19 +242,51 @@ async def get_document_links(
|
||||
detail="Access denied"
|
||||
)
|
||||
|
||||
# 링크 조회 (JOIN으로 대상 문서 정보도 함께)
|
||||
# 모든 링크 조회 (문서→문서 + 문서→노트)
|
||||
result = await db.execute(
|
||||
select(DocumentLink, Document)
|
||||
.join(Document, DocumentLink.target_document_id == Document.id)
|
||||
select(DocumentLink)
|
||||
.where(DocumentLink.source_document_id == document_id)
|
||||
.order_by(DocumentLink.start_offset.asc())
|
||||
)
|
||||
|
||||
links_with_targets = result.all()
|
||||
all_links = result.scalars().all()
|
||||
print(f"🔍 문서 링크 조회 완료: {len(all_links)}개 발견")
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_links = []
|
||||
for link, target_doc in links_with_targets:
|
||||
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),
|
||||
@@ -242,9 +303,10 @@ async def get_document_links(
|
||||
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_doc.title,
|
||||
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None
|
||||
# 대상 문서/노트 정보 추가
|
||||
target_document_title=target_title,
|
||||
target_document_book_id=target_book_id,
|
||||
target_content_type=target_content_type
|
||||
))
|
||||
|
||||
return response_links
|
||||
@@ -384,6 +446,7 @@ async def update_document_link(
|
||||
|
||||
|
||||
@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),
|
||||
@@ -419,6 +482,7 @@ class BacklinkResponse(BaseModel):
|
||||
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 # 소스 문서에서 선택한 텍스트
|
||||
@@ -467,8 +531,12 @@ async def get_document_backlinks(
|
||||
|
||||
# 이 문서를 대상으로 하는 모든 링크 조회 (백링크)
|
||||
from ...models import Book
|
||||
from ...models.note_link import NoteLink
|
||||
from ...models.note_document import NoteDocument
|
||||
from ...models.notebook import Notebook
|
||||
|
||||
query = select(DocumentLink, Document, Book).join(
|
||||
# 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_(
|
||||
@@ -478,12 +546,13 @@ async def get_document_backlinks(
|
||||
)
|
||||
).order_by(DocumentLink.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
doc_result = await db.execute(doc_query)
|
||||
backlinks = []
|
||||
|
||||
print(f"🔍 백링크 쿼리 실행 완료")
|
||||
print(f"🔍 문서 백링크 쿼리 실행 완료")
|
||||
|
||||
for link, source_doc, book in result.fetchall():
|
||||
# 일반 문서 백링크 처리
|
||||
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}")
|
||||
@@ -495,6 +564,7 @@ async def get_document_backlinks(
|
||||
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, # 소스 문서에서 선택한 텍스트 (참고용)
|
||||
@@ -509,7 +579,57 @@ async def get_document_backlinks(
|
||||
created_at=link.created_at.isoformat()
|
||||
))
|
||||
|
||||
print(f"✅ 총 {len(backlinks)}개의 백링크 반환")
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
문서 관리 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_, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -17,7 +17,7 @@ from ...core.config import settings
|
||||
from ...models.user import User
|
||||
from ...models.document import Document, Tag
|
||||
from ...models.book import Book
|
||||
from ..dependencies import get_current_active_user, get_current_admin_user
|
||||
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
|
||||
|
||||
@@ -468,7 +468,8 @@ async def get_document(
|
||||
@router.get("/{document_id}/content")
|
||||
async def get_document_content(
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
_token: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user_with_token_param),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""문서 HTML 콘텐츠 조회"""
|
||||
@@ -507,6 +508,207 @@ async def get_document_content(
|
||||
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
|
||||
@@ -620,13 +822,14 @@ async def update_document(
|
||||
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,
|
||||
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
|
||||
matched_pdf_id=str(document.matched_pdf_id) if document.matched_pdf_id else None,
|
||||
original_filename=document.original_filename
|
||||
)
|
||||
|
||||
|
||||
|
||||
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 ""
|
||||
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"}
|
||||
@@ -33,7 +33,7 @@ router = APIRouter()
|
||||
# 용어 정의: 하이라이트에 달리는 짧은 코멘트
|
||||
|
||||
@router.post("/")
|
||||
async def create_note(
|
||||
def create_note(
|
||||
note_data: dict,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
@@ -65,6 +65,62 @@ async def create_note(
|
||||
|
||||
return note
|
||||
|
||||
@router.put("/{note_id}")
|
||||
def update_note(
|
||||
note_id: str,
|
||||
note_data: dict,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""하이라이트 메모 업데이트"""
|
||||
from ...models.note import Note
|
||||
from ...models.highlight import Highlight
|
||||
|
||||
# 메모 존재 및 소유권 확인
|
||||
note = db.query(Note).join(Highlight).filter(
|
||||
Note.id == note_id,
|
||||
Highlight.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
# 메모 업데이트
|
||||
if 'content' in note_data:
|
||||
note.content = note_data['content']
|
||||
if 'tags' in note_data:
|
||||
note.tags = note_data['tags']
|
||||
|
||||
note.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return note
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
def delete_highlight_note(
|
||||
note_id: str,
|
||||
db: Session = Depends(get_sync_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""하이라이트 메모 삭제"""
|
||||
from ...models.note import Note
|
||||
from ...models.highlight import Highlight
|
||||
|
||||
note = db.query(Note).join(Highlight).filter(
|
||||
Note.id == note_id,
|
||||
Highlight.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
|
||||
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
|
||||
return {"message": "메모가 삭제되었습니다"}
|
||||
|
||||
@router.get("/document/{document_id}")
|
||||
async def get_document_notes(
|
||||
document_id: str,
|
||||
@@ -118,7 +174,7 @@ def calculate_word_count(content: str) -> int:
|
||||
|
||||
return korean_chars + english_words
|
||||
|
||||
@router.get("/", response_model=List[NoteDocumentListItem])
|
||||
@router.get("/")
|
||||
def get_notes(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
@@ -128,10 +184,34 @@ def get_notes(
|
||||
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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,8 +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, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes
|
||||
from .api.routes.notes import router as note_documents_router
|
||||
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
|
||||
@@ -30,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=["*"],
|
||||
)
|
||||
|
||||
@@ -43,21 +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=["문서 링크"])
|
||||
app.include_router(note_documents_router, prefix="/api/note-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("/")
|
||||
|
||||
@@ -12,6 +12,8 @@ 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",
|
||||
@@ -25,5 +27,9 @@ __all__ = [
|
||||
"NoteDocument",
|
||||
"Notebook",
|
||||
"NoteHighlight",
|
||||
"NoteNote"
|
||||
"NoteNote",
|
||||
"NoteLink",
|
||||
"MemoTree",
|
||||
"MemoNode",
|
||||
"MemoTreeShare"
|
||||
]
|
||||
|
||||
@@ -19,8 +19,8 @@ class DocumentLink(Base):
|
||||
# 링크가 생성된 문서 (출발점)
|
||||
source_document_id = Column(UUID(as_uuid=True), ForeignKey('documents.id'), nullable=False, index=True)
|
||||
|
||||
# 링크 대상 문서 (도착점)
|
||||
target_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) # 선택된 텍스트
|
||||
@@ -44,9 +44,9 @@ class DocumentLink(Base):
|
||||
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("Document", foreign_keys=[target_document_id], backref="incoming_links")
|
||||
# target_document relationship 제거 (노트 ID도 포함할 수 있으므로)
|
||||
creator = relationship("User", backref="created_links")
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -46,6 +46,7 @@ class NoteDocumentBase(BaseModel):
|
||||
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):
|
||||
@@ -58,6 +59,7 @@ class NoteDocumentUpdate(BaseModel):
|
||||
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):
|
||||
@@ -87,6 +89,7 @@ class NoteDocumentResponse(NoteDocumentBase):
|
||||
'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,
|
||||
|
||||
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})>"
|
||||
|
||||
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,7 +1,7 @@
|
||||
"""
|
||||
사용자 모델
|
||||
"""
|
||||
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
|
||||
@@ -21,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())
|
||||
@@ -34,6 +45,7 @@ class User(Base):
|
||||
# 관계 (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:
|
||||
|
||||
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' # 쿼리 통계 모듈
|
||||
|
||||
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>
|
||||
@@ -127,55 +127,6 @@
|
||||
<p class="text-gray-500">이 서적에 등록된 문서가 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 매칭 섹션 -->
|
||||
<div x-show="availablePDFs.length > 0" 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-file-pdf mr-2 text-red-600"></i>
|
||||
사용 가능한 PDF 문서
|
||||
<span class="ml-2 px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full" x-text="availablePDFs.length"></span>
|
||||
</h2>
|
||||
<p class="text-gray-600 text-sm mt-1">이 서적과 연결할 수 있는 PDF 문서들입니다</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="grid gap-4">
|
||||
<template x-for="pdf in availablePDFs" :key="pdf.id">
|
||||
<div class="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-1" x-text="pdf.title"></h3>
|
||||
<p class="text-gray-600 text-sm mb-2" x-text="pdf.description || '설명이 없습니다'"></p>
|
||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-file-pdf mr-1 text-red-500"></i>
|
||||
<span x-text="pdf.original_filename"></span>
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
<span x-text="formatDate(pdf.created_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button @click="matchPDFToBook(pdf.id)"
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm">
|
||||
<i class="fas fa-link mr-1"></i>서적에 연결
|
||||
</button>
|
||||
<button @click="openPDF(pdf)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="PDF 열기">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript 파일들 -->
|
||||
|
||||
@@ -146,14 +146,19 @@
|
||||
<!-- PDF 매칭 및 컨트롤 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- PDF 매칭 드롭다운 -->
|
||||
<div class="min-w-48">
|
||||
<div class="min-w-48 relative">
|
||||
<select x-model="doc.matched_pdf_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm">
|
||||
: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>
|
||||
|
||||
<!-- 이동 버튼 -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- 공통 헤더 컴포넌트 -->
|
||||
<header class="header-modern fade-in">
|
||||
<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">
|
||||
<!-- 로고 -->
|
||||
@@ -8,101 +8,224 @@
|
||||
<h1 class="text-xl font-bold text-gray-900">Document Server</h1>
|
||||
</div>
|
||||
|
||||
<!-- 메인 네비게이션 - 3가지 기능 -->
|
||||
<nav class="flex space-x-6">
|
||||
<!-- 문서 관리 시스템 -->
|
||||
<!-- 메인 네비게이션 -->
|
||||
<nav class="hidden md:flex items-center space-x-1 relative">
|
||||
<!-- 문서 관리 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<a href="index.html" class="nav-link" id="doc-nav-link">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
<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"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<a href="index.html" class="nav-dropdown-item" id="index-nav-item">
|
||||
<i class="fas fa-th-large mr-2 text-blue-500"></i>문서 관리
|
||||
</a>
|
||||
<a href="pdf-manager.html" class="nav-dropdown-item" id="pdf-manager-nav-item">
|
||||
<i class="fas fa-file-pdf mr-2 text-red-500"></i>PDF 관리
|
||||
</a>
|
||||
<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">
|
||||
<a href="memo-tree.html" class="nav-link" id="novel-nav-link">
|
||||
<i class="fas fa-feather-alt"></i>
|
||||
<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"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<a href="memo-tree.html" class="nav-dropdown-item" id="memo-tree-nav-item">
|
||||
<i class="fas fa-sitemap mr-2 text-purple-500"></i>트리 뷰
|
||||
</a>
|
||||
<a href="story-view.html" class="nav-dropdown-item" id="story-view-nav-item">
|
||||
<i class="fas fa-book-open mr-2 text-orange-500"></i>스토리 뷰
|
||||
</a>
|
||||
<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">
|
||||
<a href="notes.html" class="nav-link" id="notes-nav-link">
|
||||
<i class="fas fa-sticky-note"></i>
|
||||
<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"></i>
|
||||
</a>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<a href="notebooks.html" class="nav-dropdown-item" id="notebooks-nav-item">
|
||||
<i class="fas fa-book mr-2 text-blue-500"></i>노트북 관리
|
||||
</a>
|
||||
<a href="notes.html" class="nav-dropdown-item" id="notes-list-nav-item">
|
||||
<i class="fas fa-list mr-2 text-green-500"></i>노트 목록
|
||||
</a>
|
||||
<a href="note-editor.html" class="nav-dropdown-item" id="note-editor-nav-item">
|
||||
<i class="fas fa-edit mr-2 text-purple-500"></i>새 노트 작성
|
||||
</a>
|
||||
<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">
|
||||
<!-- PDF 관리 버튼 -->
|
||||
<a href="pdf-manager.html" class="nav-link" title="PDF 관리">
|
||||
<i class="fas fa-file-pdf text-red-500"></i>
|
||||
<span class="hidden sm:inline">PDF</span>
|
||||
</a>
|
||||
|
||||
|
||||
<!-- 언어 전환 버튼 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button class="nav-link" title="언어 설정">
|
||||
<i class="fas fa-globe"></i>
|
||||
<span class="hidden sm:inline">한국어</span>
|
||||
<i class="fas fa-chevron-down text-xs ml-1"></i>
|
||||
</button>
|
||||
<div x-show="open" x-transition class="nav-dropdown">
|
||||
<button class="nav-dropdown-item" onclick="handleLanguageChange('ko')">
|
||||
<i class="fas fa-flag mr-2 text-blue-500"></i>한국어
|
||||
</button>
|
||||
<button class="nav-dropdown-item" onclick="handleLanguageChange('en')">
|
||||
<i class="fas fa-flag mr-2 text-red-500"></i>English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인/로그아웃 -->
|
||||
<!-- 사용자 계정 메뉴 -->
|
||||
<div class="flex items-center space-x-3" id="user-menu">
|
||||
<!-- 로그인된 사용자 -->
|
||||
<div class="hidden" id="logged-in-menu">
|
||||
<span class="text-sm text-gray-600" id="user-name">User</span>
|
||||
<button onclick="handleLogout()" class="btn-improved btn-secondary-improved text-sm">
|
||||
<i class="fas fa-sign-out-alt"></i> 로그아웃
|
||||
<!-- 로그인된 사용자 드롭다운 -->
|
||||
<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="hidden" id="login-button">
|
||||
<button onclick="handleLogin()" class="btn-improved btn-primary-improved">
|
||||
<i class="fas fa-sign-in-alt"></i> 로그인
|
||||
<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>
|
||||
@@ -113,35 +236,99 @@
|
||||
|
||||
<!-- 헤더 관련 스타일 -->
|
||||
<style>
|
||||
/* 네비게이션 링크 스타일 */
|
||||
.nav-link {
|
||||
@apply text-gray-600 hover:text-blue-600 flex items-center space-x-1 py-2 px-3 rounded-lg transition-all duration-200;
|
||||
/* 모던 네비게이션 링크 스타일 */
|
||||
.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.active {
|
||||
@apply text-blue-600 bg-blue-50 font-medium;
|
||||
.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:hover {
|
||||
@apply bg-gray-50;
|
||||
.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 {
|
||||
@apply absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg py-2 min-w-44 z-50;
|
||||
/* 와이드 드롭다운 메뉴 스타일 */
|
||||
.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-item {
|
||||
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150;
|
||||
.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-item.active {
|
||||
@apply text-blue-600 bg-blue-50 font-medium;
|
||||
.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 border-b border-gray-200 shadow-sm;
|
||||
@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;
|
||||
}
|
||||
|
||||
/* 언어 전환 스타일 */
|
||||
@@ -212,34 +399,9 @@
|
||||
|
||||
// 로그인 관련 함수들
|
||||
window.handleLogin = () => {
|
||||
console.log('🔐 handleLogin 호출됨');
|
||||
|
||||
// Alpine.js 컨텍스트에서 함수 찾기
|
||||
const bodyElement = document.querySelector('body');
|
||||
if (bodyElement && bodyElement._x_dataStack) {
|
||||
const alpineData = bodyElement._x_dataStack[0];
|
||||
if (alpineData && typeof alpineData.openLoginModal === 'function') {
|
||||
console.log('✅ Alpine 컨텍스트에서 openLoginModal 호출');
|
||||
alpineData.openLoginModal();
|
||||
return;
|
||||
}
|
||||
if (alpineData && alpineData.showLoginModal !== undefined) {
|
||||
console.log('✅ Alpine 컨텍스트에서 showLoginModal 설정');
|
||||
alpineData.showLoginModal = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 함수로 시도
|
||||
if (typeof window.openLoginModal === 'function') {
|
||||
console.log('✅ 전역 openLoginModal 호출');
|
||||
window.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 직접 이벤트 발생
|
||||
console.log('🔄 커스텀 이벤트로 로그인 모달 열기');
|
||||
document.dispatchEvent(new CustomEvent('open-login-modal'));
|
||||
console.log('🔐 handleLogin 호출됨 - 로그인 페이지로 이동');
|
||||
const currentUrl = encodeURIComponent(window.location.href);
|
||||
window.location.href = `login.html?redirect=${currentUrl}`;
|
||||
};
|
||||
|
||||
window.handleLogout = () => {
|
||||
@@ -253,22 +415,73 @@
|
||||
|
||||
// 사용자 상태 업데이트 함수
|
||||
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) {
|
||||
// 로그인된 상태
|
||||
if (loggedInMenu) loggedInMenu.classList.remove('hidden');
|
||||
if (loginButton) loginButton.classList.add('hidden');
|
||||
if (userName) userName.textContent = user.username || user.full_name || user.email || '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) => {
|
||||
@@ -329,9 +542,27 @@
|
||||
// 향후 다국어 지원 시 구현
|
||||
};
|
||||
|
||||
// 헤더 로드 완료 후 언어 설정 적용
|
||||
// 헤더 로드 완료 후 이벤트 바인딩
|
||||
document.addEventListener('headerLoaded', () => {
|
||||
console.log('🔧 헤더 로드 완료 - 언어 전환 함수 등록');
|
||||
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);
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<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>
|
||||
@@ -55,7 +58,7 @@
|
||||
<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">
|
||||
|
||||
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>
|
||||
@@ -597,6 +597,21 @@
|
||||
.status-writing { border-left-color: #f59e0b; }
|
||||
.status-review { border-left-color: #3b82f6; }
|
||||
.status-complete { border-left-color: #10b981; }
|
||||
|
||||
/* 드래그 앤 드롭 스타일 */
|
||||
.drop-target-highlight {
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
border: 2px dashed #3b82f6 !important;
|
||||
transform: scale(1.05) !important;
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.05);
|
||||
z-index: 1000;
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -641,7 +656,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="h-screen pt-16" x-show="currentUser">
|
||||
<div class="h-screen pt-20" x-show="currentUser">
|
||||
<!-- 상단 툴바 -->
|
||||
<div class="bg-white border-b shadow-sm p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -764,7 +779,8 @@
|
||||
<div
|
||||
class="absolute tree-diagram-node"
|
||||
:style="getNodePosition(node)"
|
||||
@mousedown="startDragNode($event, node)"
|
||||
:data-node-id="node.id"
|
||||
@click="selectNode(node)"
|
||||
>
|
||||
<div
|
||||
class="tree-node-modern p-4 cursor-pointer min-w-40 max-w-56 relative"
|
||||
@@ -772,7 +788,6 @@
|
||||
'tree-node-canonical': node.is_canonical,
|
||||
'ring-2 ring-blue-400': selectedNode && selectedNode.id === node.id
|
||||
}"
|
||||
@click="selectNode(node)"
|
||||
@dblclick="editNodeInline(node)"
|
||||
>
|
||||
<!-- 정사 경로 배지 -->
|
||||
@@ -977,6 +992,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 토스트 알림 -->
|
||||
<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="!currentUser" class="flex items-center justify-center h-screen">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
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.name"></option>
|
||||
<option :value="notebook.id" x-text="notebook.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
.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()">
|
||||
@@ -30,7 +42,7 @@
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
@@ -200,7 +212,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 노트북 메타데이터 -->
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<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">
|
||||
@@ -212,6 +224,28 @@
|
||||
</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>
|
||||
@@ -229,6 +263,35 @@
|
||||
</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"
|
||||
@@ -321,6 +384,66 @@
|
||||
</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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
@@ -137,7 +137,7 @@
|
||||
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.name"></option>
|
||||
<option :value="notebook.id" x-text="notebook.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
<option value="">전체</option>
|
||||
<option value="unassigned">미분류</option>
|
||||
<template x-for="notebook in availableNotebooks" :key="notebook.id">
|
||||
<option :value="notebook.id" x-text="notebook.name"></option>
|
||||
<option :value="notebook.id" x-text="notebook.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -193,6 +193,12 @@
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<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 다운로드">
|
||||
@@ -225,10 +231,86 @@
|
||||
</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=2025012459"></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 |
@@ -255,8 +255,8 @@ class DocumentServerAPI {
|
||||
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) {
|
||||
@@ -266,22 +266,28 @@ class DocumentServerAPI {
|
||||
// === 하이라이트 메모 (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}`);
|
||||
@@ -375,10 +381,6 @@ class DocumentServerAPI {
|
||||
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}`);
|
||||
}
|
||||
@@ -500,10 +502,6 @@ class DocumentServerAPI {
|
||||
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}`);
|
||||
}
|
||||
@@ -617,6 +615,11 @@ class DocumentServerAPI {
|
||||
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) {
|
||||
@@ -694,6 +697,53 @@ class DocumentServerAPI {
|
||||
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 인스턴스
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -38,6 +38,9 @@ window.bookDocumentsApp = () => ({
|
||||
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'));
|
||||
},
|
||||
|
||||
// 인증 상태 확인
|
||||
@@ -166,11 +169,23 @@ window.bookDocumentsApp = () => ({
|
||||
|
||||
// 서적 편집 페이지 열기
|
||||
openBookEditor() {
|
||||
console.log('🔧 서적 편집 버튼 클릭됨');
|
||||
console.log('📖 현재 bookId:', this.bookId);
|
||||
console.log('🔍 bookId 타입:', typeof this.bookId);
|
||||
|
||||
if (this.bookId === 'none') {
|
||||
alert('서적 미분류 문서들은 편집할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
window.location.href = `book-editor.html?bookId=${this.bookId}`;
|
||||
|
||||
if (!this.bookId) {
|
||||
alert('서적 ID가 없습니다. 페이지를 새로고침해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUrl = `book-editor.html?bookId=${this.bookId}`;
|
||||
console.log('🔗 이동할 URL:', targetUrl);
|
||||
window.location.href = targetUrl;
|
||||
},
|
||||
|
||||
// 문서 수정
|
||||
|
||||
@@ -90,6 +90,25 @@ window.bookEditorApp = () => ({
|
||||
|
||||
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);
|
||||
@@ -112,11 +131,17 @@ window.bookEditorApp = () => ({
|
||||
|
||||
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, '개');
|
||||
@@ -128,6 +153,23 @@ window.bookEditorApp = () => ({
|
||||
})));
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -206,24 +248,36 @@ window.bookEditorApp = () => ({
|
||||
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 매칭 정보 업데이트
|
||||
const updatePromises = this.documents.map(doc => {
|
||||
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 || null
|
||||
matched_pdf_id: doc.matched_pdf_id === "" || doc.matched_pdf_id === null ? null : doc.matched_pdf_id
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
const results = await Promise.all(updatePromises);
|
||||
console.log('✅ 모든 문서 업데이트 완료:', results.length, '개');
|
||||
|
||||
console.log('✅ 모든 변경사항 저장 완료');
|
||||
this.showNotification('변경사항이 저장되었습니다', 'success');
|
||||
@@ -234,7 +288,7 @@ window.bookEditorApp = () => ({
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
console.error('❌ 저장 실패:', error);
|
||||
this.showNotification('저장에 실패했습니다: ' + error.message, 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
|
||||
@@ -124,7 +124,11 @@ class HeaderLoader {
|
||||
'index': 'index-nav-item',
|
||||
'hierarchy': 'hierarchy-nav-item',
|
||||
'memo-tree': 'memo-tree-nav-item',
|
||||
'story-view': 'story-view-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];
|
||||
@@ -151,9 +155,109 @@ document.addEventListener('headerLoaded', () => {
|
||||
setTimeout(() => {
|
||||
window.headerLoader.updateActiveStates();
|
||||
|
||||
// 사용자 메뉴 초기 상태 설정 (로그아웃 상태로 시작)
|
||||
if (typeof window.updateUserMenu === 'function') {
|
||||
window.updateUserMenu(null);
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,15 @@ window.documentApp = () => ({
|
||||
this.isAuthenticated = false;
|
||||
this.currentUser = null;
|
||||
localStorage.removeItem('access_token');
|
||||
|
||||
// 로그인 페이지로 리다이렉트 (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.syncUIState(); // UI 상태 동기화
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,6 +23,13 @@ window.memoTreeApp = function() {
|
||||
showLoginModal: false,
|
||||
showMobileEditModal: false,
|
||||
|
||||
// 알림 시스템
|
||||
notification: {
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'info' // 'success', 'error', 'info'
|
||||
},
|
||||
|
||||
// 로그인 폼 상태
|
||||
loginForm: {
|
||||
email: '',
|
||||
@@ -56,9 +63,6 @@ window.memoTreeApp = function() {
|
||||
treePanX: 0,
|
||||
treePanY: 0,
|
||||
nodePositions: new Map(), // 노드 ID -> {x, y} 위치 매핑
|
||||
isDragging: false,
|
||||
dragNode: null,
|
||||
dragOffset: { x: 0, y: 0 },
|
||||
|
||||
// 로그인 관련 함수들
|
||||
openLoginModal() {
|
||||
@@ -283,7 +287,15 @@ window.memoTreeApp = function() {
|
||||
|
||||
const node = await window.api.createMemoNode(nodeData);
|
||||
this.treeNodes.push(node);
|
||||
this.selectNode(node);
|
||||
|
||||
// 노드 위치 재계산 (새 노드 추가 후)
|
||||
this.$nextTick(() => {
|
||||
this.calculateNodePositions();
|
||||
// 위치 계산 완료 후 새 노드 선택
|
||||
setTimeout(() => {
|
||||
this.selectNode(node);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
console.log('✅ 루트 노드 생성 완료');
|
||||
} catch (error) {
|
||||
@@ -294,6 +306,11 @@ window.memoTreeApp = function() {
|
||||
|
||||
// 노드 선택
|
||||
selectNode(node) {
|
||||
// 현재 팬 값 저장 (위치 변경 방지)
|
||||
const currentPanX = this.treePanX;
|
||||
const currentPanY = this.treePanY;
|
||||
const currentZoom = this.treeZoom;
|
||||
|
||||
// 이전 노드 저장
|
||||
if (this.selectedNode && this.isEditorDirty) {
|
||||
this.saveNode();
|
||||
@@ -316,6 +333,11 @@ window.memoTreeApp = function() {
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// 팬 값 복원 (위치 변경 방지)
|
||||
this.treePanX = currentPanX;
|
||||
this.treePanY = currentPanY;
|
||||
this.treeZoom = currentZoom;
|
||||
|
||||
console.log('📝 노드 선택:', node.title);
|
||||
},
|
||||
|
||||
@@ -503,6 +525,20 @@ window.memoTreeApp = function() {
|
||||
},
|
||||
|
||||
// 트리 타입별 아이콘 가져오기
|
||||
// 알림 표시
|
||||
showNotification(message, type = 'info') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
message: message,
|
||||
type: type
|
||||
};
|
||||
|
||||
// 3초 후 자동으로 숨김
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
getTreeIcon(treeType) {
|
||||
const icons = {
|
||||
novel: '📚',
|
||||
@@ -531,8 +567,14 @@ window.memoTreeApp = function() {
|
||||
// 부모 노드 펼치기
|
||||
this.expandedNodes.add(parentNode.id);
|
||||
|
||||
// 새 노드 선택
|
||||
this.selectNode(node);
|
||||
// 노드 위치 재계산 (새 노드 추가 후)
|
||||
this.$nextTick(() => {
|
||||
this.calculateNodePositions();
|
||||
// 위치 계산 완료 후 새 노드 선택
|
||||
setTimeout(() => {
|
||||
this.selectNode(node);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
console.log('✅ 자식 노드 생성 완료');
|
||||
} catch (error) {
|
||||
@@ -602,12 +644,32 @@ window.memoTreeApp = function() {
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 드래그 시작
|
||||
startDragNode(event, node) {
|
||||
// 현재는 드래그 기능 비활성화 (패닝과 충돌 방지)
|
||||
event.stopPropagation();
|
||||
console.log('드래그 시작:', node.title);
|
||||
// TODO: 노드 드래그 앤 드롭 구현
|
||||
|
||||
|
||||
|
||||
|
||||
// 노드를 다른 부모로 이동
|
||||
async moveNodeToParent(nodeId, newParentId) {
|
||||
try {
|
||||
console.log(`📦 노드 이동: ${nodeId} -> 부모: ${newParentId}`);
|
||||
|
||||
const moveData = {
|
||||
parent_id: newParentId,
|
||||
sort_order: 0 // 새 부모의 첫 번째 자식으로
|
||||
};
|
||||
|
||||
await window.api.moveMemoNode(nodeId, moveData);
|
||||
|
||||
// 트리 다시 로드
|
||||
await this.loadTreeNodes();
|
||||
|
||||
console.log('✅ 노드 이동 완료');
|
||||
this.showNotification('노드가 이동되었습니다.', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 노드 이동 실패:', error);
|
||||
this.showNotification('노드 이동에 실패했습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// 노드 인라인 편집
|
||||
@@ -628,15 +690,12 @@ window.memoTreeApp = function() {
|
||||
|
||||
// 노드 위치 계산 및 반환
|
||||
getNodePosition(node) {
|
||||
if (!this.nodePositions.has(node.id)) {
|
||||
this.calculateNodePositions();
|
||||
}
|
||||
|
||||
// 위치가 없으면 기본 위치 반환 (전체 재계산 방지)
|
||||
const pos = this.nodePositions.get(node.id) || { x: 0, y: 0 };
|
||||
return `left: ${pos.x}px; top: ${pos.y}px;`;
|
||||
},
|
||||
|
||||
// 트리 노드 위치 자동 계산
|
||||
// 트리 노드 위치 자동 계산 (가로 방향: 왼쪽에서 오른쪽)
|
||||
calculateNodePositions() {
|
||||
const canvas = document.getElementById('tree-canvas');
|
||||
if (!canvas) return;
|
||||
@@ -647,11 +706,11 @@ window.memoTreeApp = function() {
|
||||
// 노드 크기 설정
|
||||
const nodeWidth = 200;
|
||||
const nodeHeight = 80;
|
||||
const levelHeight = 150; // 레벨 간 간격
|
||||
const nodeSpacing = 50; // 노드 간 간격
|
||||
const levelWidth = 250; // 레벨 간 가로 간격 (왼쪽에서 오른쪽)
|
||||
const nodeSpacing = 100; // 노드 간 세로 간격
|
||||
const margin = 100; // 여백
|
||||
|
||||
// 레벨별 노드 그룹화
|
||||
// 레벨별 노드 그룹화 (가로 방향)
|
||||
const levels = new Map();
|
||||
|
||||
// 루트 노드들 찾기
|
||||
@@ -659,7 +718,7 @@ window.memoTreeApp = function() {
|
||||
|
||||
if (rootNodes.length === 0) return;
|
||||
|
||||
// BFS로 레벨별 노드 배치
|
||||
// BFS로 레벨별 노드 배치 (가로 방향)
|
||||
const queue = [];
|
||||
rootNodes.forEach(node => {
|
||||
queue.push({ node, level: 0 });
|
||||
@@ -680,25 +739,25 @@ window.memoTreeApp = function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 트리 전체 크기 계산
|
||||
// 트리 전체 크기 계산 (가로 방향)
|
||||
const maxLevel = Math.max(...levels.keys());
|
||||
const maxNodesInLevel = Math.max(...Array.from(levels.values()).map(nodes => nodes.length));
|
||||
|
||||
const treeWidth = maxNodesInLevel * nodeWidth + (maxNodesInLevel - 1) * nodeSpacing;
|
||||
const treeHeight = (maxLevel + 1) * levelHeight;
|
||||
const treeWidth = (maxLevel + 1) * levelWidth; // 가로 방향 전체 너비
|
||||
const treeHeight = maxNodesInLevel * nodeHeight + (maxNodesInLevel - 1) * nodeSpacing; // 세로 방향 전체 높이
|
||||
|
||||
// 캔버스 중앙에 트리 배치하기 위한 오프셋 계산
|
||||
const offsetX = Math.max(margin, (canvasWidth - treeWidth) / 2);
|
||||
const offsetY = Math.max(margin, (canvasHeight - treeHeight) / 2);
|
||||
|
||||
// 각 레벨의 노드들 위치 계산
|
||||
// 각 레벨의 노드들 위치 계산 (가로 방향)
|
||||
levels.forEach((nodes, level) => {
|
||||
const y = offsetY + level * levelHeight;
|
||||
const levelWidth = nodes.length * nodeWidth + (nodes.length - 1) * nodeSpacing;
|
||||
const startX = offsetX + (treeWidth - levelWidth) / 2;
|
||||
const x = offsetX + level * levelWidth; // 가로 위치 (왼쪽에서 오른쪽)
|
||||
const levelHeight = nodes.length * nodeHeight + (nodes.length - 1) * nodeSpacing;
|
||||
const startY = offsetY + (treeHeight - levelHeight) / 2; // 세로 중앙 정렬
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
const x = startX + index * (nodeWidth + nodeSpacing);
|
||||
const y = startY + index * (nodeHeight + nodeSpacing);
|
||||
this.nodePositions.set(node.id, { x, y });
|
||||
});
|
||||
});
|
||||
@@ -738,17 +797,17 @@ window.memoTreeApp = function() {
|
||||
// 연결선 생성
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
|
||||
// 부모 노드 하단 중앙에서 시작
|
||||
const startX = parentPos.x + 100; // 노드 중앙
|
||||
const startY = parentPos.y + 80; // 노드 하단
|
||||
// 부모 노드 오른쪽 중앙에서 시작 (가로 방향)
|
||||
const startX = parentPos.x + 200; // 노드 오른쪽 끝
|
||||
const startY = parentPos.y + 40; // 노드 세로 중앙
|
||||
|
||||
// 자식 노드 상단 중앙으로 연결
|
||||
const endX = childPos.x + 100; // 노드 중앙
|
||||
const endY = childPos.y; // 노드 상단
|
||||
// 자식 노드 왼쪽 중앙으로 연결 (가로 방향)
|
||||
const endX = childPos.x; // 노드 왼쪽 끝
|
||||
const endY = childPos.y + 40; // 노드 세로 중앙
|
||||
|
||||
// 곡선 경로 생성 (베지어 곡선)
|
||||
const midY = startY + (endY - startY) / 2;
|
||||
const path = `M ${startX} ${startY} C ${startX} ${midY} ${endX} ${midY} ${endX} ${endY}`;
|
||||
// 곡선 경로 생성 (베지어 곡선, 가로 방향)
|
||||
const midX = startX + (endX - startX) / 2;
|
||||
const path = `M ${startX} ${startY} C ${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`;
|
||||
|
||||
line.setAttribute('d', path);
|
||||
line.setAttribute('stroke', '#9CA3AF');
|
||||
|
||||
@@ -54,13 +54,21 @@ function noteEditorApp() {
|
||||
return;
|
||||
}
|
||||
|
||||
// URL에서 노트 ID 확인 (편집 모드)
|
||||
// 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);
|
||||
@@ -119,6 +127,14 @@ function noteEditorApp() {
|
||||
// 임시: 직접 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 = [];
|
||||
|
||||
@@ -7,6 +7,13 @@ window.notebooksApp = () => ({
|
||||
saving: false,
|
||||
error: '',
|
||||
|
||||
// 알림 시스템
|
||||
notification: {
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'info' // 'success', 'error', 'info'
|
||||
},
|
||||
|
||||
// 필터링
|
||||
searchQuery: '',
|
||||
activeOnly: true,
|
||||
@@ -18,7 +25,10 @@ window.notebooksApp = () => ({
|
||||
// 모달 상태
|
||||
showCreateModal: false,
|
||||
showEditModal: false,
|
||||
showDeleteModal: false,
|
||||
editingNotebook: null,
|
||||
deletingNotebook: null,
|
||||
deleting: false,
|
||||
|
||||
// 노트북 폼
|
||||
notebookForm: {
|
||||
@@ -168,7 +178,12 @@ window.notebooksApp = () => ({
|
||||
|
||||
// 노트북 열기 (노트 목록으로 이동)
|
||||
openNotebook(notebook) {
|
||||
window.location.href = `/notes.html?notebook_id=${notebook.id}¬ebook_name=${encodeURIComponent(notebook.name)}`;
|
||||
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)}`;
|
||||
},
|
||||
|
||||
// 노트북 편집
|
||||
@@ -185,22 +200,37 @@ window.notebooksApp = () => ({
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
// 노트북 삭제
|
||||
async deleteNotebook(notebook) {
|
||||
if (!confirm(`"${notebook.title}" 노트북을 삭제하시겠습니까?\n\n${notebook.note_count > 0 ? `포함된 ${notebook.note_count}개의 노트는 미분류 상태가 됩니다.` : ''}`)) {
|
||||
return;
|
||||
}
|
||||
// 노트북 삭제 (모달 표시)
|
||||
deleteNotebook(notebook) {
|
||||
this.deletingNotebook = notebook;
|
||||
this.showDeleteModal = true;
|
||||
},
|
||||
|
||||
// 삭제 확인
|
||||
async confirmDeleteNotebook() {
|
||||
if (!this.deletingNotebook) return;
|
||||
|
||||
this.deleting = true;
|
||||
try {
|
||||
await this.api.deleteNotebook(notebook.id, true); // force=true
|
||||
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()) {
|
||||
@@ -266,12 +296,15 @@ window.notebooksApp = () => ({
|
||||
|
||||
// 알림 표시
|
||||
showNotification(message, type = 'info') {
|
||||
if (type === 'error') {
|
||||
alert('❌ ' + message);
|
||||
} else if (type === 'success') {
|
||||
alert('✅ ' + message);
|
||||
} else {
|
||||
alert('ℹ️ ' + message);
|
||||
}
|
||||
this.notification = {
|
||||
show: true,
|
||||
message: message,
|
||||
type: type
|
||||
};
|
||||
|
||||
// 3초 후 자동으로 숨김
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,6 +11,14 @@ window.pdfManagerApp = () => ({
|
||||
isAuthenticated: false,
|
||||
currentUser: null,
|
||||
|
||||
// PDF 미리보기 상태
|
||||
showPreviewModal: false,
|
||||
previewPdf: null,
|
||||
pdfPreviewSrc: '',
|
||||
pdfPreviewLoading: false,
|
||||
pdfPreviewError: false,
|
||||
pdfPreviewLoaded: false,
|
||||
|
||||
// 초기화
|
||||
async init() {
|
||||
console.log('🚀 PDF Manager App 초기화 시작');
|
||||
@@ -213,6 +221,56 @@ window.pdfManagerApp = () => ({
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 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 '';
|
||||
|
||||
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 로드 완료');
|
||||
@@ -17,7 +17,10 @@ window.storyViewApp = function() {
|
||||
// UI 상태
|
||||
showLoginModal: false,
|
||||
showEditModal: false,
|
||||
editingNode: null,
|
||||
editingNode: {
|
||||
title: '',
|
||||
content: ''
|
||||
},
|
||||
|
||||
// 로그인 폼 상태
|
||||
loginForm: {
|
||||
@@ -79,11 +82,22 @@ window.storyViewApp = function() {
|
||||
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 = [];
|
||||
}
|
||||
},
|
||||
|
||||
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('📋 할일관리 컴포넌트 등록 완료');
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 간단한 테스트용 documentViewer
|
||||
*/
|
||||
window.documentViewer = () => ({
|
||||
// 기본 상태
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// 네비게이션
|
||||
navigation: null,
|
||||
|
||||
// 검색
|
||||
searchQuery: '',
|
||||
|
||||
// 데이터
|
||||
notes: [],
|
||||
bookmarks: [],
|
||||
documentLinks: [],
|
||||
backlinks: [],
|
||||
|
||||
// UI 상태
|
||||
activeFeatureMenu: null,
|
||||
selectedHighlightColor: '#FFFF00',
|
||||
|
||||
// 모달 상태
|
||||
showLinksModal: false,
|
||||
showLinkModal: false,
|
||||
showNotesModal: false,
|
||||
showBookmarksModal: false,
|
||||
showBacklinksModal: false,
|
||||
|
||||
// 폼 데이터
|
||||
linkForm: {
|
||||
target_document_id: '',
|
||||
selected_text: '',
|
||||
book_scope: 'same',
|
||||
target_book_id: '',
|
||||
link_type: 'document',
|
||||
target_text: '',
|
||||
description: ''
|
||||
},
|
||||
|
||||
// 기타 데이터
|
||||
availableBooks: [],
|
||||
filteredDocuments: [],
|
||||
|
||||
// 초기화
|
||||
init() {
|
||||
console.log('🔧 간단한 documentViewer 로드됨');
|
||||
this.documentId = new URLSearchParams(window.location.search).get('id');
|
||||
console.log('📋 문서 ID:', this.documentId);
|
||||
},
|
||||
|
||||
// 뒤로가기
|
||||
goBack() {
|
||||
console.log('🔙 뒤로가기 클릭됨');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const fromPage = urlParams.get('from');
|
||||
|
||||
if (fromPage === 'index') {
|
||||
window.location.href = '/index.html';
|
||||
} else if (fromPage === 'hierarchy') {
|
||||
window.location.href = '/hierarchy.html';
|
||||
} else {
|
||||
window.location.href = '/index.html';
|
||||
}
|
||||
},
|
||||
|
||||
// 기본 함수들
|
||||
toggleFeatureMenu(feature) {
|
||||
console.log('🎯 기능 메뉴 토글:', feature);
|
||||
this.activeFeatureMenu = this.activeFeatureMenu === feature ? null : feature;
|
||||
},
|
||||
|
||||
searchInDocument() {
|
||||
console.log('🔍 문서 검색:', this.searchQuery);
|
||||
},
|
||||
|
||||
// 빈 함수들 (오류 방지용)
|
||||
navigateToDocument() { console.log('네비게이션 함수 호출됨'); },
|
||||
goToBookContents() { console.log('목차로 이동 함수 호출됨'); },
|
||||
createHighlightWithColor() { console.log('하이라이트 생성 함수 호출됨'); },
|
||||
resetTargetSelection() { console.log('타겟 선택 리셋 함수 호출됨'); },
|
||||
loadDocumentsFromBook() { console.log('서적 문서 로드 함수 호출됨'); },
|
||||
onTargetDocumentChange() { console.log('타겟 문서 변경 함수 호출됨'); },
|
||||
openTargetDocumentSelector() { console.log('타겟 문서 선택기 열기 함수 호출됨'); },
|
||||
saveDocumentLink() { console.log('문서 링크 저장 함수 호출됨'); },
|
||||
closeLinkModal() { console.log('링크 모달 닫기 함수 호출됨'); },
|
||||
getSelectedBookTitle() { return '테스트 서적'; }
|
||||
});
|
||||
|
||||
console.log('✅ 테스트용 documentViewer 정의됨');
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,15 @@ class DocumentLoader {
|
||||
document.title = `${noteDocument.title} - Document Server`;
|
||||
|
||||
// 노트 내용을 HTML로 설정
|
||||
const contentElement = document.getElementById('document-content');
|
||||
if (contentElement && noteDocument.content) {
|
||||
contentElement.innerHTML = noteDocument.content;
|
||||
const noteContentElement = document.getElementById('note-content');
|
||||
if (noteContentElement && noteDocument.content) {
|
||||
noteContentElement.innerHTML = noteDocument.content;
|
||||
} else {
|
||||
// 폴백: document-content 사용
|
||||
const contentElement = document.getElementById('document-content');
|
||||
if (contentElement && noteDocument.content) {
|
||||
contentElement.innerHTML = noteDocument.content;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📝 노트 로드 완료:', noteDocument.title);
|
||||
@@ -46,25 +52,28 @@ class DocumentLoader {
|
||||
// 백엔드에서 문서 정보 가져오기 (캐싱 적용)
|
||||
const docData = await this.cachedApi.get(`/documents/${documentId}`, { content_type: 'document' }, { category: 'document' });
|
||||
|
||||
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
|
||||
const htmlPath = docData.html_path;
|
||||
const fileName = htmlPath.split('/').pop();
|
||||
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('문서 파일을 불러올 수 없습니다');
|
||||
}
|
||||
|
||||
const htmlContent = await response.text();
|
||||
document.getElementById('document-content').innerHTML = htmlContent;
|
||||
|
||||
// 페이지 제목 업데이트
|
||||
document.title = `${docData.title} - Document Server`;
|
||||
|
||||
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의
|
||||
this.setupDocumentScriptHandlers();
|
||||
// PDF 문서가 아닌 경우에만 HTML 로드
|
||||
if (!docData.pdf_path && docData.html_path) {
|
||||
// HTML 파일 경로 구성 (백엔드 서버를 통해 접근)
|
||||
const htmlPath = docData.html_path;
|
||||
const fileName = htmlPath.split('/').pop();
|
||||
const response = await fetch(`http://localhost:24102/uploads/documents/${fileName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('문서 파일을 불러올 수 없습니다');
|
||||
}
|
||||
|
||||
const htmlContent = await response.text();
|
||||
document.getElementById('document-content').innerHTML = htmlContent;
|
||||
|
||||
// 문서 내 스크립트 오류 방지를 위한 전역 함수들 정의
|
||||
this.setupDocumentScriptHandlers();
|
||||
}
|
||||
|
||||
console.log('✅ 문서 로드 완료:', docData.title);
|
||||
console.log('✅ 문서 로드 완료:', docData.title, docData.pdf_path ? '(PDF)' : '(HTML)');
|
||||
return docData;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,14 +42,20 @@ class HighlightManager {
|
||||
*/
|
||||
async loadNotes(documentId, contentType) {
|
||||
try {
|
||||
console.log('📝 메모 로드 시작:', { documentId, contentType });
|
||||
|
||||
if (contentType === 'note') {
|
||||
this.notes = await this.api.get(`/note/${documentId}/notes`).catch(() => []);
|
||||
// 노트 문서의 하이라이트 메모
|
||||
this.notes = await this.api.get(`/highlight-notes/`, { note_document_id: documentId }).catch(() => []);
|
||||
} else {
|
||||
this.notes = await this.cachedApi.get('/notes', { document_id: documentId, content_type: contentType }, { category: 'notes' }).catch(() => []);
|
||||
// 일반 문서의 하이라이트 메모
|
||||
this.notes = await this.api.get(`/highlight-notes/`, { document_id: documentId }).catch(() => []);
|
||||
}
|
||||
|
||||
console.log('📝 메모 로드 완료:', this.notes.length, '개');
|
||||
return this.notes || [];
|
||||
} catch (error) {
|
||||
console.error('메모 로드 실패:', error);
|
||||
console.error('❌ 메모 로드 실패:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -235,12 +241,46 @@ class HighlightManager {
|
||||
span.style.cursor = 'help';
|
||||
}
|
||||
|
||||
// 하이라이트 클릭 이벤트 추가
|
||||
// 하이라이트 클릭 이벤트 추가 (통합 툴팁 사용)
|
||||
span.style.cursor = 'pointer';
|
||||
span.addEventListener('click', (e) => {
|
||||
span.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.showHighlightModal(highlights);
|
||||
|
||||
console.log('🎨 하이라이트 클릭됨:', {
|
||||
text: span.textContent,
|
||||
highlightId: span.dataset.highlightId,
|
||||
classList: Array.from(span.classList)
|
||||
});
|
||||
|
||||
// 링크, 백링크, 하이라이트 모두 찾기
|
||||
const overlappingElements = window.documentViewerInstance.getOverlappingElements(span);
|
||||
const totalElements = overlappingElements.links.length + overlappingElements.backlinks.length + overlappingElements.highlights.length;
|
||||
|
||||
console.log('🎨 하이라이트 클릭 분석:', {
|
||||
links: overlappingElements.links.length,
|
||||
backlinks: overlappingElements.backlinks.length,
|
||||
highlights: overlappingElements.highlights.length,
|
||||
total: totalElements,
|
||||
selectedText: overlappingElements.selectedText
|
||||
});
|
||||
|
||||
if (totalElements > 1) {
|
||||
// 통합 툴팁 표시 (링크 + 백링크 + 하이라이트)
|
||||
console.log('🎯 통합 툴팁 표시 시작 (하이라이트에서)');
|
||||
await window.documentViewerInstance.showUnifiedTooltip(overlappingElements, span);
|
||||
} else {
|
||||
// 단일 하이라이트 툴팁
|
||||
console.log('🎨 단일 하이라이트 툴팁 표시');
|
||||
// 클릭된 하이라이트 찾기
|
||||
const clickedHighlightId = span.dataset.highlightId;
|
||||
const clickedHighlight = this.highlights.find(h => h.id === clickedHighlightId);
|
||||
if (clickedHighlight) {
|
||||
await this.showHighlightTooltip(clickedHighlight, span);
|
||||
} else {
|
||||
console.error('❌ 클릭된 하이라이트를 찾을 수 없음:', clickedHighlightId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DOM 교체
|
||||
@@ -488,6 +528,109 @@ class HighlightManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 하이라이트 색상 변경
|
||||
*/
|
||||
async updateHighlightColor(highlightId, newColor) {
|
||||
try {
|
||||
console.log('🎨 하이라이트 색상 업데이트:', highlightId, newColor);
|
||||
|
||||
// API 호출 (구현 필요)
|
||||
await this.api.updateHighlight(highlightId, { highlight_color: newColor });
|
||||
|
||||
// 로컬 데이터 업데이트
|
||||
const highlight = this.highlights.find(h => h.id === highlightId);
|
||||
if (highlight) {
|
||||
highlight.highlight_color = newColor;
|
||||
}
|
||||
|
||||
// 하이라이트 다시 렌더링
|
||||
this.renderHighlights();
|
||||
this.hideTooltip();
|
||||
|
||||
console.log('✅ 하이라이트 색상 변경 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 하이라이트 색상 변경 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 하이라이트 복사
|
||||
*/
|
||||
async duplicateHighlight(highlightId) {
|
||||
try {
|
||||
console.log('📋 하이라이트 복사:', highlightId);
|
||||
|
||||
const originalHighlight = this.highlights.find(h => h.id === highlightId);
|
||||
if (!originalHighlight) {
|
||||
throw new Error('원본 하이라이트를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 새 하이라이트 데이터 생성 (약간 다른 위치에)
|
||||
const duplicateData = {
|
||||
document_id: originalHighlight.document_id,
|
||||
start_offset: originalHighlight.start_offset,
|
||||
end_offset: originalHighlight.end_offset,
|
||||
selected_text: originalHighlight.selected_text,
|
||||
highlight_color: originalHighlight.highlight_color,
|
||||
highlight_type: originalHighlight.highlight_type
|
||||
};
|
||||
|
||||
// API 호출
|
||||
const newHighlight = await this.api.createHighlight(duplicateData);
|
||||
|
||||
// 로컬 데이터에 추가
|
||||
this.highlights.push(newHighlight);
|
||||
|
||||
// 하이라이트 다시 렌더링
|
||||
this.renderHighlights();
|
||||
this.hideTooltip();
|
||||
|
||||
console.log('✅ 하이라이트 복사 완료:', newHighlight);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 하이라이트 복사 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모 업데이트
|
||||
*/
|
||||
async updateNote(noteId, newContent) {
|
||||
try {
|
||||
console.log('✏️ 메모 업데이트:', noteId, newContent);
|
||||
|
||||
// API 호출
|
||||
const apiToUse = this.cachedApi || this.api;
|
||||
await apiToUse.updateNote(noteId, { content: newContent });
|
||||
|
||||
// 로컬 데이터 업데이트
|
||||
const note = this.notes.find(n => n.id === noteId);
|
||||
if (note) {
|
||||
note.content = newContent;
|
||||
}
|
||||
|
||||
// 툴팁 새로고침 (현재 표시 중인 경우)
|
||||
const tooltip = document.getElementById('highlight-tooltip');
|
||||
if (tooltip) {
|
||||
// 간단히 툴팁을 다시 로드하는 대신 텍스트만 업데이트
|
||||
const noteElement = document.querySelector(`[data-note-id="${noteId}"] .text-gray-800`);
|
||||
if (noteElement) {
|
||||
noteElement.textContent = newContent;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ 메모 업데이트 완료');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 메모 업데이트 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 오프셋 계산
|
||||
*/
|
||||
@@ -587,7 +730,21 @@ class HighlightManager {
|
||||
async deleteHighlight(highlightId) {
|
||||
try {
|
||||
await this.api.delete(`/highlights/${highlightId}`);
|
||||
|
||||
// 하이라이트 배열에서 제거
|
||||
this.highlights = this.highlights.filter(h => h.id !== highlightId);
|
||||
|
||||
// 메모 배열에서도 해당 하이라이트의 메모들 제거
|
||||
this.notes = this.notes.filter(note => note.highlight_id !== highlightId);
|
||||
|
||||
// 캐시 무효화 (하이라이트와 메모 모두)
|
||||
if (window.documentViewerInstance && window.documentViewerInstance.cacheManager) {
|
||||
window.documentViewerInstance.cacheManager.invalidateCategory('highlights');
|
||||
window.documentViewerInstance.cacheManager.invalidateCategory('notes');
|
||||
console.log('🗑️ 하이라이트 삭제 후 캐시 무효화 완료');
|
||||
}
|
||||
|
||||
// 화면 다시 렌더링
|
||||
this.renderHighlights();
|
||||
console.log('하이라이트 삭제 완료:', highlightId);
|
||||
} catch (error) {
|
||||
@@ -719,13 +876,70 @@ class HighlightManager {
|
||||
return colorNames[color] || '기타';
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅 (상세)
|
||||
*/
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '알 수 없음';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷팅 (간단)
|
||||
*/
|
||||
formatShortDate(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', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 하이라이트 툴팁 표시
|
||||
*/
|
||||
showHighlightTooltip(clickedHighlight, element) {
|
||||
async showHighlightTooltip(clickedHighlight, element) {
|
||||
// 기존 말풍선 제거
|
||||
this.hideTooltip();
|
||||
|
||||
// 메모 데이터 다시 로드 (최신 상태 보장)
|
||||
console.log('📝 하이라이트 툴팁용 메모 로드 시작...');
|
||||
const documentId = window.documentViewerInstance.documentId;
|
||||
const contentType = window.documentViewerInstance.contentType;
|
||||
|
||||
console.log('📝 메모 로드 파라미터:', { documentId, contentType });
|
||||
console.log('📝 기존 메모 개수:', this.notes ? this.notes.length : 'undefined');
|
||||
|
||||
await this.loadNotes(documentId, contentType);
|
||||
|
||||
console.log('📝 메모 로드 완료:', this.notes.length, '개');
|
||||
console.log('📝 로드된 메모 상세:', this.notes.map(n => ({
|
||||
id: n.id,
|
||||
highlight_id: n.highlight_id,
|
||||
content: n.content,
|
||||
created_at: n.created_at
|
||||
})));
|
||||
|
||||
// 동일한 범위의 모든 하이라이트 찾기
|
||||
const overlappingHighlights = this.findOverlappingHighlights(clickedHighlight);
|
||||
const colorGroups = this.groupHighlightsByColor(overlappingHighlights);
|
||||
@@ -744,9 +958,19 @@ class HighlightManager {
|
||||
|
||||
let tooltipHTML = `
|
||||
<div class="mb-4">
|
||||
<div class="text-sm text-gray-600 mb-2">선택된 텍스트</div>
|
||||
<div class="font-medium text-gray-900 bg-gray-100 px-3 py-2 rounded border-l-4 border-blue-500">
|
||||
"${longestText}"
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-lg font-semibold text-blue-800 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
|
||||
</svg>
|
||||
하이라이트 정보
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">${overlappingHighlights.length}개 하이라이트</div>
|
||||
</div>
|
||||
|
||||
<div class="font-medium text-gray-900 bg-blue-50 px-4 py-3 rounded-lg border-l-4 border-blue-500">
|
||||
<div class="text-sm text-blue-700 mb-1">선택된 텍스트</div>
|
||||
<div class="text-base">"${longestText}"</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -756,39 +980,84 @@ class HighlightManager {
|
||||
|
||||
Object.entries(colorGroups).forEach(([color, highlights]) => {
|
||||
const colorName = this.getColorName(color);
|
||||
const allNotes = highlights.flatMap(h =>
|
||||
this.notes.filter(note => note.highlight_id === h.id)
|
||||
);
|
||||
|
||||
// 각 하이라이트에 대한 메모 찾기 (디버깅 로그 추가)
|
||||
const allNotes = highlights.flatMap(h => {
|
||||
const notesForHighlight = this.notes.filter(note => note.highlight_id === h.id);
|
||||
console.log(`📝 하이라이트 ${h.id}에 대한 메모:`, notesForHighlight.length, '개');
|
||||
if (notesForHighlight.length > 0) {
|
||||
console.log('📝 메모 내용:', notesForHighlight.map(n => n.content));
|
||||
}
|
||||
return notesForHighlight;
|
||||
});
|
||||
|
||||
console.log(`🎨 ${colorName} 하이라이트의 총 메모:`, allNotes.length, '개');
|
||||
|
||||
const createdDate = highlights[0].created_at ? this.formatDate(highlights[0].created_at) : '알 수 없음';
|
||||
|
||||
tooltipHTML += `
|
||||
<div class="border rounded-lg p-3" style="border-left: 4px solid ${color}">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded" style="background-color: ${color}"></div>
|
||||
<span class="text-sm font-medium text-gray-700">${colorName} 메모 (${allNotes.length})</span>
|
||||
<div class="border rounded-lg p-4 bg-gradient-to-r from-gray-50 to-gray-100" style="border-left: 4px solid ${color}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-4 h-4 rounded-full shadow-sm" style="background-color: ${color}"></div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-gray-800">${colorName} 하이라이트</span>
|
||||
<div class="text-xs text-gray-600">${createdDate} 생성</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="window.documentViewerInstance.highlightManager.changeHighlightColor('${highlights[0].id}')"
|
||||
class="text-xs bg-gray-500 text-white px-2 py-1 rounded hover:bg-gray-600 transition-colors"
|
||||
title="색상 변경">
|
||||
🎨
|
||||
</button>
|
||||
<button onclick="window.documentViewerInstance.highlightManager.showAddNoteForm('${highlights[0].id}')"
|
||||
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600 transition-colors">
|
||||
📝 메모 추가
|
||||
</button>
|
||||
</div>
|
||||
<button onclick="window.documentViewerInstance.highlightManager.showAddNoteForm('${highlights[0].id}')"
|
||||
class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600">
|
||||
+ 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notes-list-${highlights[0].id}" class="space-y-2 max-h-32 overflow-y-auto">
|
||||
${allNotes.length > 0 ?
|
||||
allNotes.map(note => `
|
||||
<div class="bg-gray-50 p-2 rounded text-sm">
|
||||
<div class="text-gray-800">${note.content}</div>
|
||||
<div class="text-xs text-gray-500 mt-1 flex justify-between items-center">
|
||||
<span>${this.formatShortDate(note.created_at)} · Administrator</span>
|
||||
<button onclick="window.documentViewerInstance.highlightManager.deleteNote('${note.id}')"
|
||||
class="text-red-600 hover:text-red-800">
|
||||
삭제
|
||||
</button>
|
||||
<!-- 메모 목록 -->
|
||||
<div class="mb-3">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"></path>
|
||||
<path fill-rule="evenodd" d="M4 5a2 2 0 012-2v1a1 1 0 102 0V3a2 2 0 012 0v1a1 1 0 102 0V3a2 2 0 012 2v6.586A2 2 0 0115.414 13L13 15.586A2 2 0 0111.586 16H6a2 2 0 01-2-2V5zm8 4a1 1 0 10-2 0v2a1 1 0 102 0V9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
메모 (${allNotes.length}개)
|
||||
</div>
|
||||
|
||||
<div id="notes-list-${highlights[0].id}" class="space-y-2 max-h-40 overflow-y-auto">
|
||||
${allNotes.length > 0 ?
|
||||
allNotes.map(note => `
|
||||
<div class="bg-white p-3 rounded-lg border shadow-sm group">
|
||||
<div class="text-gray-800 text-sm leading-relaxed">${note.content}</div>
|
||||
<div class="text-xs text-gray-500 mt-2 flex justify-between items-center">
|
||||
<span class="flex items-center">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
${this.formatShortDate(note.created_at)}
|
||||
</span>
|
||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onclick="window.documentViewerInstance.highlightManager.editNote('${note.id}', '${note.content.replace(/'/g, "\\'")}');"
|
||||
class="text-blue-600 hover:text-blue-800 mr-2 text-xs"
|
||||
title="메모 편집">
|
||||
✏️
|
||||
</button>
|
||||
<button onclick="window.documentViewerInstance.highlightManager.deleteNote('${note.id}')"
|
||||
class="text-red-600 hover:text-red-800 text-xs"
|
||||
title="메모 삭제">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') :
|
||||
'<div class="text-sm text-gray-500 italic">메모가 없습니다</div>'
|
||||
}
|
||||
`).join('') :
|
||||
'<div class="text-sm text-gray-500 italic bg-white p-3 rounded-lg border">메모가 없습니다. 위의 "📝 메모 추가" 버튼을 클릭해보세요!</div>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -796,13 +1065,36 @@ class HighlightManager {
|
||||
|
||||
tooltipHTML += '</div>';
|
||||
|
||||
// 하이라이트 삭제 버튼
|
||||
// 하이라이트 관리 버튼들
|
||||
tooltipHTML += `
|
||||
<div class="mt-4 pt-3 border-t">
|
||||
<button onclick="window.documentViewerInstance.highlightManager.deleteHighlight('${clickedHighlight.id}')"
|
||||
class="text-xs bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600">
|
||||
하이라이트 삭제
|
||||
</button>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-medium text-gray-700 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
하이라이트 관리
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button onclick="window.documentViewerInstance.highlightManager.duplicateHighlight('${clickedHighlight.id}')"
|
||||
class="text-xs bg-green-500 text-white px-3 py-1 rounded hover:bg-green-600 transition-colors flex items-center"
|
||||
title="하이라이트 복사">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"></path>
|
||||
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z"></path>
|
||||
</svg>
|
||||
복사
|
||||
</button>
|
||||
<button onclick="window.documentViewerInstance.deleteHighlightWithConfirm('${clickedHighlight.id}')"
|
||||
class="text-xs bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600 transition-colors flex items-center"
|
||||
title="하이라이트 삭제">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -232,22 +232,24 @@ class CachedAPI {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 기존 API 메서드 직접 사용
|
||||
// 하이라이트 메모 API 사용
|
||||
try {
|
||||
let result;
|
||||
if (contentType === 'note') {
|
||||
result = await this.api.get(`/note/${documentId}/notes`).catch(() => []);
|
||||
// 노트 문서의 하이라이트 메모
|
||||
result = await this.api.get(`/highlight-notes/`, { note_document_id: documentId }).catch(() => []);
|
||||
} else {
|
||||
result = await this.api.getDocumentNotes(documentId).catch(() => []);
|
||||
// 일반 문서의 하이라이트 메모
|
||||
result = await this.api.get(`/highlight-notes/`, { document_id: documentId }).catch(() => []);
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
this.cache.set(cacheKey, result, 'notes', 10 * 60 * 1000);
|
||||
console.log(`💾 메모 캐시 저장: ${documentId}`);
|
||||
console.log(`💾 메모 캐시 저장: ${documentId} (${result.length}개)`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('메모 로드 실패:', error);
|
||||
console.error('❌ 메모 로드 실패:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -359,7 +361,25 @@ class CachedAPI {
|
||||
* 메모 생성 (캐시 무효화)
|
||||
*/
|
||||
async createNote(data) {
|
||||
return await this.post('/notes/', data, {
|
||||
return await this.post('/highlight-notes/', data, {
|
||||
invalidateCategories: ['notes', 'highlights']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모 업데이트 (캐시 무효화)
|
||||
*/
|
||||
async updateNote(noteId, data) {
|
||||
return await this.put(`/highlight-notes/${noteId}`, data, {
|
||||
invalidateCategories: ['notes', 'highlights']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모 삭제 (캐시 무효화)
|
||||
*/
|
||||
async deleteNote(noteId) {
|
||||
return await this.delete(`/highlight-notes/${noteId}`, {
|
||||
invalidateCategories: ['notes', 'highlights']
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen pt-4" x-show="currentUser">
|
||||
<div class="min-h-screen pt-20" x-show="currentUser">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
<!-- 상단 네비게이션 바 -->
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="min-h-screen pt-4" x-show="currentUser">
|
||||
<div class="min-h-screen pt-20" x-show="currentUser">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
<!-- 상단 툴바 -->
|
||||
@@ -54,8 +54,8 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<select
|
||||
x-model="selectedTreeId"
|
||||
@change="loadStory(selectedTreeId)"
|
||||
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@change="loadStory($event.target.value)"
|
||||
class="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer relative z-[60]"
|
||||
>
|
||||
<option value="">📚 스토리 선택</option>
|
||||
<template x-for="tree in userTrees" :key="tree.id">
|
||||
@@ -63,6 +63,7 @@
|
||||
</template>
|
||||
</select>
|
||||
|
||||
|
||||
<div x-show="selectedTree" class="text-sm text-gray-600">
|
||||
<span x-text="`총 ${canonicalNodes.length}개 챕터`"></span>
|
||||
<span class="mx-2">•</span>
|
||||
@@ -178,7 +179,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 p-6 overflow-hidden">
|
||||
<div class="flex-1 p-6 overflow-hidden" x-show="editingNode">
|
||||
<div class="h-full flex flex-col space-y-4">
|
||||
<!-- 제목 편집 -->
|
||||
<div>
|
||||
@@ -288,17 +289,31 @@
|
||||
// 스크립트 순차 로딩
|
||||
(async () => {
|
||||
try {
|
||||
await loadScript('static/js/api.js?v=2025012380');
|
||||
console.log('🚀 스크립트 로딩 시작...');
|
||||
|
||||
await loadScript('static/js/api.js?v=2025012627');
|
||||
console.log('✅ API 스크립트 로드 완료');
|
||||
await loadScript('static/js/story-view.js?v=2025012364');
|
||||
|
||||
await loadScript('static/js/story-view.js?v=2025012627');
|
||||
console.log('✅ Story View 스크립트 로드 완료');
|
||||
|
||||
// Alpine.js 로드 전에 함수 등록 확인
|
||||
console.log('🔍 storyViewApp 함수 확인:', typeof window.storyViewApp);
|
||||
|
||||
// 모든 스크립트 로드 완료 후 Alpine.js 로드
|
||||
console.log('🚀 Alpine.js 로딩...');
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js');
|
||||
console.log('✅ Alpine.js 로드 완료');
|
||||
|
||||
// Alpine.js 초기화 확인
|
||||
setTimeout(() => {
|
||||
console.log('🔍 Alpine 객체 확인:', typeof Alpine);
|
||||
console.log('🔍 Alpine 초기화 상태:', Alpine ? 'OK' : 'FAILED');
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 스크립트 로드 실패:', error);
|
||||
console.error('❌ 에러 상세:', error.message);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
368
frontend/system-settings.html
Normal file
368
frontend/system-settings.html
Normal file
@@ -0,0 +1,368 @@
|
||||
<!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">
|
||||
|
||||
<!-- 인증 가드 -->
|
||||
<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="systemSettingsApp()" x-init="init()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8 max-w-6xl">
|
||||
<!-- 권한 확인 중 로딩 -->
|
||||
<div x-show="!permissionChecked" class="flex items-center justify-center py-20">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-blue-600 mb-4"></i>
|
||||
<p class="text-gray-600">권한을 확인하고 있습니다...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 권한 없음 -->
|
||||
<div x-show="permissionChecked && !hasPermission" class="text-center py-20">
|
||||
<div class="bg-red-100 border border-red-200 rounded-lg p-8 max-w-md mx-auto">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-600 mb-4"></i>
|
||||
<h2 class="text-xl font-semibold text-red-800 mb-2">접근 권한이 없습니다</h2>
|
||||
<p class="text-red-600 mb-4">시스템 설정에 접근하려면 관리자 권한이 필요합니다.</p>
|
||||
<button onclick="history.back()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
|
||||
<i class="fas fa-arrow-left mr-2"></i>이전 페이지로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 설정 (권한 있는 경우만 표시) -->
|
||||
<div x-show="permissionChecked && hasPermission">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3 mb-2">
|
||||
<i class="fas fa-server text-2xl text-blue-600"></i>
|
||||
<h1 class="text-3xl font-bold text-gray-900">시스템 설정</h1>
|
||||
</div>
|
||||
<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-100 text-green-800 border border-green-200' : 'bg-red-100 text-red-800 border border-red-200'">
|
||||
<div class="flex items-center">
|
||||
<i :class="notification.type === 'success' ? 'fas fa-check-circle text-green-600' : 'fas fa-exclamation-circle text-red-600'" class="mr-2"></i>
|
||||
<span x-text="notification.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설정 섹션들 -->
|
||||
<div class="space-y-8">
|
||||
<!-- 시스템 정보 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-info-circle text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">시스템 정보</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<i class="fas fa-users text-blue-600"></i>
|
||||
<span class="font-medium text-gray-700">총 사용자 수</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.totalUsers">-</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<i class="fas fa-user-shield text-green-600"></i>
|
||||
<span class="font-medium text-gray-700">활성 사용자</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.activeUsers">-</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<i class="fas fa-crown text-yellow-600"></i>
|
||||
<span class="font-medium text-gray-700">관리자 수</span>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-900" x-text="systemInfo.adminUsers">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 설정 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-cog text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">기본 설정</h2>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateSystemSettings()" class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">시스템 이름</label>
|
||||
<input type="text" x-model="settings.systemName"
|
||||
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="Document Server">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">기본 언어</label>
|
||||
<select x-model="settings.defaultLanguage"
|
||||
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 class="block text-sm font-medium text-gray-700 mb-2">기본 테마</label>
|
||||
<select x-model="settings.defaultTheme"
|
||||
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 class="block text-sm font-medium text-gray-700 mb-2">기본 세션 타임아웃 (분)</label>
|
||||
<select x-model="settings.defaultSessionTimeout"
|
||||
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="5">5분</option>
|
||||
<option value="15">15분</option>
|
||||
<option value="30">30분</option>
|
||||
<option value="60">1시간</option>
|
||||
<option value="0">무제한</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="loading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2">
|
||||
<i class="fas fa-save"></i>
|
||||
<span x-text="loading ? '저장 중...' : '설정 저장'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 관리 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-users-cog text-xl text-blue-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">사용자 관리</h2>
|
||||
</div>
|
||||
<a href="user-management.html" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center space-x-2">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
<span>사용자 관리</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-600">
|
||||
<p class="mb-2">• 새로운 사용자 계정 생성 및 관리</p>
|
||||
<p class="mb-2">• 사용자 권한 설정 (서적관리, 노트관리, 소설관리)</p>
|
||||
<p class="mb-2">• 개별 사용자 세션 타임아웃 설정</p>
|
||||
<p>• 사용자 계정 활성화/비활성화</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 유지보수 -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center space-x-3 mb-6">
|
||||
<i class="fas fa-tools text-xl text-orange-600"></i>
|
||||
<h2 class="text-xl font-semibold text-gray-900">시스템 유지보수</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-gray-900 mb-2">캐시 정리</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">시스템 캐시를 정리하여 성능을 개선합니다.</p>
|
||||
<button @click="clearCache()" :disabled="loading"
|
||||
class="w-full px-3 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
<i class="fas fa-broom mr-2"></i>캐시 정리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<h3 class="font-medium text-gray-900 mb-2">시스템 재시작</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">시스템을 재시작하여 설정을 적용합니다.</p>
|
||||
<button @click="restartSystem()" :disabled="loading"
|
||||
class="w-full px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<i class="fas fa-power-off mr-2"></i>시스템 재시작
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- API 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
|
||||
<!-- 시스템 설정 스크립트 -->
|
||||
<script>
|
||||
function systemSettingsApp() {
|
||||
return {
|
||||
loading: false,
|
||||
permissionChecked: false,
|
||||
hasPermission: false,
|
||||
currentUser: null,
|
||||
systemInfo: {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
adminUsers: 0
|
||||
},
|
||||
settings: {
|
||||
systemName: 'Document Server',
|
||||
defaultLanguage: 'ko',
|
||||
defaultTheme: 'light',
|
||||
defaultSessionTimeout: 5
|
||||
},
|
||||
notification: {
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'success'
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 시스템 설정 앱 초기화');
|
||||
await this.checkPermission();
|
||||
if (this.hasPermission) {
|
||||
await this.loadSystemInfo();
|
||||
await this.loadSettings();
|
||||
}
|
||||
},
|
||||
|
||||
async checkPermission() {
|
||||
try {
|
||||
this.currentUser = await api.getCurrentUser();
|
||||
// root, admin 역할이거나 is_admin이 true인 경우 권한 허용
|
||||
this.hasPermission = this.currentUser.role === 'root' ||
|
||||
this.currentUser.role === 'admin' ||
|
||||
this.currentUser.is_admin === true;
|
||||
|
||||
console.log('👤 현재 사용자:', this.currentUser);
|
||||
console.log('🔐 관리자 권한:', this.hasPermission);
|
||||
} catch (error) {
|
||||
console.error('❌ 권한 확인 실패:', error);
|
||||
this.hasPermission = false;
|
||||
} finally {
|
||||
this.permissionChecked = true;
|
||||
}
|
||||
},
|
||||
|
||||
async loadSystemInfo() {
|
||||
try {
|
||||
const users = await api.get('/users');
|
||||
this.systemInfo.totalUsers = users.length;
|
||||
this.systemInfo.activeUsers = users.filter(u => u.is_active).length;
|
||||
this.systemInfo.adminUsers = users.filter(u => u.role === 'root' || u.role === 'admin' || u.is_admin).length;
|
||||
|
||||
console.log('📊 시스템 정보 로드 완료:', this.systemInfo);
|
||||
} catch (error) {
|
||||
console.error('❌ 시스템 정보 로드 실패:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async loadSettings() {
|
||||
// 실제로는 백엔드에서 시스템 설정을 가져와야 하지만,
|
||||
// 현재는 기본값을 사용
|
||||
console.log('⚙️ 시스템 설정 로드 (기본값 사용)');
|
||||
},
|
||||
|
||||
async updateSystemSettings() {
|
||||
this.loading = true;
|
||||
try {
|
||||
// 실제로는 백엔드 API를 호출해야 함
|
||||
console.log('💾 시스템 설정 저장:', this.settings);
|
||||
|
||||
// 시뮬레이션을 위한 지연
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
this.showNotification('시스템 설정이 성공적으로 저장되었습니다.', 'success');
|
||||
} catch (error) {
|
||||
console.error('❌ 시스템 설정 저장 실패:', error);
|
||||
this.showNotification('시스템 설정 저장에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async clearCache() {
|
||||
if (!confirm('시스템 캐시를 정리하시겠습니까?')) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 실제로는 백엔드 API를 호출해야 함
|
||||
console.log('🧹 캐시 정리 중...');
|
||||
|
||||
// 시뮬레이션을 위한 지연
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
this.showNotification('캐시가 성공적으로 정리되었습니다.', 'success');
|
||||
} catch (error) {
|
||||
console.error('❌ 캐시 정리 실패:', error);
|
||||
this.showNotification('캐시 정리에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async restartSystem() {
|
||||
if (!confirm('시스템을 재시작하시겠습니까? 모든 사용자의 연결이 끊어집니다.')) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 실제로는 백엔드 API를 호출해야 함
|
||||
console.log('🔄 시스템 재시작 중...');
|
||||
|
||||
this.showNotification('시스템 재시작이 요청되었습니다. 잠시 후 페이지가 새로고침됩니다.', 'success');
|
||||
|
||||
// 시뮬레이션을 위한 지연 후 페이지 새로고침
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error('❌ 시스템 재시작 실패:', error);
|
||||
this.showNotification('시스템 재시작에 실패했습니다.', 'error');
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
showNotification(message, type = 'success') {
|
||||
this.notification = {
|
||||
show: true,
|
||||
message: message,
|
||||
type: type
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
this.notification.show = false;
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,100 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>테스트 문서</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 40px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #555;
|
||||
margin-top: 30px;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 15px;
|
||||
text-align: justify;
|
||||
}
|
||||
.highlight {
|
||||
background-color: #fff3cd;
|
||||
padding: 15px;
|
||||
border-left: 4px solid #ffc107;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.code {
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
font-family: 'Courier New', monospace;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Document Server 테스트 문서</h1>
|
||||
|
||||
<p>이 문서는 Document Server의 업로드 및 뷰어 기능을 테스트하기 위한 샘플 문서입니다.
|
||||
하이라이트, 메모, 책갈피 등의 기능을 테스트할 수 있습니다.</p>
|
||||
|
||||
<h2>주요 기능</h2>
|
||||
<p>Document Server는 다음과 같은 기능을 제공합니다:</p>
|
||||
<ul>
|
||||
<li><strong>문서 업로드</strong>: HTML 및 PDF 파일 업로드</li>
|
||||
<li><strong>스마트 하이라이트</strong>: 텍스트 선택 및 하이라이트</li>
|
||||
<li><strong>연결된 메모</strong>: 하이라이트에 메모 추가</li>
|
||||
<li><strong>책갈피</strong>: 중요한 위치 저장</li>
|
||||
<li><strong>통합 검색</strong>: 문서 내용 및 메모 검색</li>
|
||||
</ul>
|
||||
|
||||
<div class="highlight">
|
||||
<strong>중요:</strong> 이 부분은 하이라이트 테스트를 위한 중요한 내용입니다.
|
||||
사용자는 이 텍스트를 선택하여 하이라이트를 추가하고 메모를 작성할 수 있습니다.
|
||||
</div>
|
||||
|
||||
<h2>기술 스택</h2>
|
||||
<p>이 프로젝트는 다음 기술들을 사용하여 구축되었습니다:</p>
|
||||
|
||||
<div class="code">
|
||||
<strong>백엔드:</strong> FastAPI, SQLAlchemy, PostgreSQL<br>
|
||||
<strong>프론트엔드:</strong> HTML5, CSS3, JavaScript, Alpine.js<br>
|
||||
<strong>인프라:</strong> Docker, Nginx
|
||||
</div>
|
||||
|
||||
<h2>사용 방법</h2>
|
||||
<p>문서를 읽으면서 다음과 같은 작업을 수행할 수 있습니다:</p>
|
||||
|
||||
<ol>
|
||||
<li>텍스트를 선택하여 하이라이트 추가</li>
|
||||
<li>하이라이트에 메모 작성</li>
|
||||
<li>중요한 위치에 책갈피 설정</li>
|
||||
<li>검색 기능을 통해 내용 찾기</li>
|
||||
</ol>
|
||||
|
||||
<p>이 문서는 업로드 테스트가 완료되면 뷰어에서 확인할 수 있으며,
|
||||
모든 annotation 기능을 테스트해볼 수 있습니다.</p>
|
||||
|
||||
<div class="highlight">
|
||||
<strong>테스트 완료:</strong> 이 문서가 정상적으로 표시되면 업로드 기능이
|
||||
올바르게 작동하는 것입니다.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>테스트 페이지</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 p-8">
|
||||
<div x-data="{ message: '페이지가 정상 작동합니다!' }">
|
||||
<h1 class="text-3xl font-bold text-blue-600 mb-4" x-text="message"></h1>
|
||||
<p class="text-gray-700">이 페이지가 보이면 기본 설정은 정상입니다.</p>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold mb-4">링크</h2>
|
||||
<div class="space-y-2">
|
||||
<a href="index.html" class="block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">그리드 뷰</a>
|
||||
<a href="hierarchy.html" class="block px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">계층구조 뷰</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -78,11 +78,14 @@
|
||||
// URL에서 문서 ID 추출
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.documentId = urlParams.get('id');
|
||||
this.contentType = urlParams.get('contentType') || 'document'; // 기본값은 document
|
||||
|
||||
if (!this.documentId) {
|
||||
this.showError('문서 ID가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔧 초기화:', { documentId: this.documentId, contentType: this.contentType });
|
||||
|
||||
// 인증 확인
|
||||
if (!api.token) {
|
||||
@@ -100,16 +103,28 @@
|
||||
}
|
||||
|
||||
async loadDocument() {
|
||||
console.log('📄 문서 로드 중:', this.documentId);
|
||||
console.log('📄 문서 로드 중:', this.documentId, 'contentType:', this.contentType);
|
||||
|
||||
try {
|
||||
// 문서 메타데이터 조회
|
||||
const docResponse = await api.getDocument(this.documentId);
|
||||
// contentType에 따라 적절한 API 호출
|
||||
let docResponse, contentEndpoint;
|
||||
|
||||
if (this.contentType === 'note') {
|
||||
// 노트 문서 메타데이터 조회
|
||||
docResponse = await api.getNoteDocument(this.documentId);
|
||||
contentEndpoint = `/note-documents/${this.documentId}/content`;
|
||||
console.log('📝 노트 메타데이터:', docResponse);
|
||||
} else {
|
||||
// 일반 문서 메타데이터 조회
|
||||
docResponse = await api.getDocument(this.documentId);
|
||||
contentEndpoint = `/documents/${this.documentId}/content`;
|
||||
console.log('📋 문서 메타데이터:', docResponse);
|
||||
}
|
||||
|
||||
this.document = docResponse;
|
||||
console.log('📋 문서 메타데이터:', docResponse);
|
||||
|
||||
// 문서 HTML 콘텐츠 조회
|
||||
const contentResponse = await fetch(`${api.baseURL}/documents/${this.documentId}/content`, {
|
||||
const contentResponse = await fetch(`${api.baseURL}${contentEndpoint}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${api.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
|
||||
674
frontend/todos.html
Normal file
674
frontend/todos.html
Normal file
@@ -0,0 +1,674 @@
|
||||
<!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>
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* 모바일 최적화 */
|
||||
@media (max-width: 768px) {
|
||||
.container { padding-left: 1rem; padding-right: 1rem; }
|
||||
.todo-input-inner { padding: 16px; }
|
||||
.todo-textarea { font-size: 16px; } /* iOS 줌 방지 */
|
||||
.todo-card { margin-bottom: 12px; }
|
||||
.modal-content { margin: 1rem; max-height: 90vh; }
|
||||
}
|
||||
|
||||
/* memos/트위터 스타일 입력창 */
|
||||
.todo-input-container {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border-radius: 16px;
|
||||
padding: 2px;
|
||||
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.todo-input-inner {
|
||||
background: white;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.todo-textarea {
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
min-height: 80px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.todo-textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 카드 스타일 */
|
||||
.todo-card {
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: white;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.todo-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.todo-card.draft::before { background: #9ca3af; }
|
||||
.todo-card.scheduled::before { background: #3b82f6; }
|
||||
.todo-card.active::before { background: #f59e0b; }
|
||||
.todo-card.completed::before { background: #10b981; }
|
||||
.todo-card.delayed::before { background: #ef4444; }
|
||||
|
||||
.todo-card:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* 상태 배지 */
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-draft { background: #f3f4f6; color: #6b7280; }
|
||||
.status-scheduled { background: #dbeafe; color: #1d4ed8; }
|
||||
.status-active { background: #fef3c7; color: #d97706; }
|
||||
.status-completed { background: #d1fae5; color: #065f46; }
|
||||
.status-delayed { background: #fee2e2; color: #dc2626; }
|
||||
|
||||
/* 시간 배지 */
|
||||
.time-badge {
|
||||
background: #f0f9ff;
|
||||
color: #0369a1;
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 탭 스타일 */
|
||||
.tab-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
/* 버튼 스타일 */
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 액션 버튼들 */
|
||||
.action-btn {
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 댓글 버블 */
|
||||
.comment-bubble {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border-left: 3px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* 모달 스타일 */
|
||||
.modal-content {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 스와이프 힌트 */
|
||||
.swipe-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 풀 투 리프레시 스타일 */
|
||||
.pull-refresh {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #6366f1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 햅틱 피드백 애니메이션 */
|
||||
@keyframes haptic-pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.haptic-feedback {
|
||||
animation: haptic-pulse 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen" x-data="todosApp()" x-init="init()" x-cloak>
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-3">
|
||||
<i class="fas fa-tasks text-indigo-600 mr-3"></i>
|
||||
할일관리
|
||||
</h1>
|
||||
<p class="text-lg md:text-xl text-gray-600 mb-4">효율적인 일정 관리와 생산성 향상</p>
|
||||
|
||||
<!-- 간단한 통계 -->
|
||||
<div class="flex justify-center space-x-6 text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-inbox w-4 h-4 mr-1 text-gray-400"></i>
|
||||
<span>검토필요 <strong x-text="stats.draft_count || 0"></strong>개</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-tasks w-4 h-4 mr-1 text-blue-500"></i>
|
||||
<span>진행중 <strong x-text="stats.todo_count || 0"></strong>개</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check w-4 h-4 mr-1 text-green-500"></i>
|
||||
<span>완료 <strong x-text="stats.completed_count || 0"></strong>개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 할일 입력 (memos/트위터 스타일) -->
|
||||
<div class="max-w-2xl mx-auto mb-8">
|
||||
<div class="todo-input-container">
|
||||
<div class="todo-input-inner">
|
||||
<textarea
|
||||
x-model="newTodoContent"
|
||||
@keydown.ctrl.enter="createTodo()"
|
||||
placeholder="새로운 할일을 입력하세요... (Ctrl+Enter로 저장)"
|
||||
class="todo-textarea w-full"
|
||||
rows="3"
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-gray-500">
|
||||
<i class="fas fa-lightbulb mr-1"></i>
|
||||
Ctrl+Enter로 빠르게 저장하세요
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="createTodo(); hapticFeedback($event.target)"
|
||||
:disabled="!newTodoContent.trim() || loading"
|
||||
class="btn-primary px-6 py-3 text-white rounded-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
<span x-show="!loading">추가</span>
|
||||
<span x-show="loading">
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i>저장 중...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 탭 네비게이션 -->
|
||||
<div class="max-w-4xl mx-auto mb-6">
|
||||
<!-- 모바일용 스와이프 힌트 -->
|
||||
<div class="swipe-hint md:hidden">
|
||||
<i class="fas fa-hand-pointer mr-1"></i>
|
||||
탭을 눌러서 전환하세요
|
||||
</div>
|
||||
|
||||
<div class="tab-container">
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button
|
||||
@click="activeTab = 'draft'; hapticFeedback($event.target)"
|
||||
:class="activeTab === 'draft' ? 'tab-button active' : 'tab-button text-gray-600 hover:text-gray-900'"
|
||||
class="px-4 py-3 text-center"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-inbox text-lg mb-1"></i>
|
||||
<span class="text-sm font-medium">검토필요</span>
|
||||
<span class="text-xs opacity-75">(<span x-text="stats.draft_count || 0"></span>)</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'todo'; hapticFeedback($event.target)"
|
||||
:class="activeTab === 'todo' ? 'tab-button active' : 'tab-button text-gray-600 hover:text-gray-900'"
|
||||
class="px-4 py-3 text-center"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-tasks text-lg mb-1"></i>
|
||||
<span class="text-sm font-medium">TODO</span>
|
||||
<span class="text-xs opacity-75">(<span x-text="stats.todo_count || 0"></span>)</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'completed'; hapticFeedback($event.target)"
|
||||
:class="activeTab === 'completed' ? 'tab-button active' : 'tab-button text-gray-600 hover:text-gray-900'"
|
||||
class="px-4 py-3 text-center"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-check text-lg mb-1"></i>
|
||||
<span class="text-sm font-medium">완료된일</span>
|
||||
<span class="text-xs opacity-75">(<span x-text="stats.completed_count || 0"></span>)</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 할일 목록 -->
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<!-- 검토필요 탭 -->
|
||||
<div x-show="activeTab === 'draft'" class="space-y-4">
|
||||
<template x-for="todo in draftTodos" :key="todo.id">
|
||||
<div class="todo-card draft bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="status-badge status-draft">검토필요</span>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(todo.created_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
@click="openScheduleModal(todo)"
|
||||
class="px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
<i class="fas fa-calendar-plus mr-1"></i>일정설정
|
||||
</button>
|
||||
<button
|
||||
@click="openSplitModal(todo)"
|
||||
class="px-3 py-1 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
|
||||
>
|
||||
<i class="fas fa-cut mr-1"></i>분할
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="draftTodos.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-inbox text-6xl text-gray-300 mb-4"></i>
|
||||
<p class="text-gray-500">검토가 필요한 할일이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TODO 탭 -->
|
||||
<div x-show="activeTab === 'todo'" class="space-y-4">
|
||||
<template x-for="todo in activeTodos" :key="todo.id">
|
||||
<div class="todo-card active bg-white rounded-lg shadow-sm p-6" x-data="{ newMemo: '', addingMemo: false }">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
|
||||
<div class="flex items-center space-x-2 mb-3">
|
||||
<span class="status-badge status-active">진행중</span>
|
||||
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(todo.start_date)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
@click="completeTodo(todo.id); hapticFeedback($event.target)"
|
||||
class="action-btn bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
<i class="fas fa-check mr-1"></i>완료
|
||||
</button>
|
||||
<button
|
||||
@click="openScheduleModal(todo); hapticFeedback($event.target)"
|
||||
class="action-btn bg-orange-600 text-white hover:bg-orange-700"
|
||||
>
|
||||
<i class="fas fa-clock mr-1"></i>지연
|
||||
</button>
|
||||
<button
|
||||
@click="toggleMemo(todo.id); hapticFeedback($event.target)"
|
||||
class="action-btn bg-indigo-600 text-white hover:bg-indigo-700"
|
||||
>
|
||||
<i class="fas fa-sticky-note mr-1"></i>메모
|
||||
<span x-show="getTodoMemoCount(todo.id) > 0" class="ml-1 bg-white text-indigo-600 rounded-full px-1 text-xs" x-text="getTodoMemoCount(todo.id)"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 인라인 메모 섹션 -->
|
||||
<div x-show="showMemoForTodo[todo.id]" x-transition class="mt-4 border-t pt-4">
|
||||
<!-- 기존 메모들 -->
|
||||
<div x-show="getTodoMemos(todo.id).length > 0" class="space-y-2 mb-3">
|
||||
<template x-for="memo in getTodoMemos(todo.id)" :key="memo.id">
|
||||
<div class="comment-bubble">
|
||||
<p class="text-sm text-gray-700" x-text="memo.content"></p>
|
||||
<div class="text-xs text-gray-500 mt-1" x-text="formatRelativeTime(memo.created_at)"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 새 메모 입력 -->
|
||||
<div class="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="newMemo"
|
||||
placeholder="메모를 입력하세요..."
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
@keydown.enter="if(newMemo.trim()) { addingMemo = true; addTodoMemo(todo.id, newMemo).then(() => { newMemo = ''; addingMemo = false; }); }"
|
||||
>
|
||||
<button
|
||||
@click="if(newMemo.trim()) { addingMemo = true; addTodoMemo(todo.id, newMemo).then(() => { newMemo = ''; addingMemo = false; }); hapticFeedback($event.target); }"
|
||||
:disabled="!newMemo.trim() || addingMemo"
|
||||
class="action-btn bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
<i class="fas fa-plus" x-show="!addingMemo"></i>
|
||||
<i class="fas fa-spinner fa-spin" x-show="addingMemo"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="activeTodos.length === 0" class="empty-state">
|
||||
<i class="fas fa-tasks text-4xl"></i>
|
||||
<p class="text-lg font-medium">할 일이 없습니다</p>
|
||||
<p class="text-sm">검토필요에서 일정을 설정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 예정된일 탭 -->
|
||||
<div x-show="activeTab === 'scheduled'" class="space-y-4">
|
||||
<template x-for="todo in scheduledTodos" :key="todo.id">
|
||||
<div class="todo-card scheduled bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="status-badge status-scheduled">예정됨</span>
|
||||
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(todo.start_date)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="scheduledTodos.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-calendar text-6xl text-gray-300 mb-4"></i>
|
||||
<p class="text-gray-500">예정된 할일이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 완료된일 탭 -->
|
||||
<div x-show="activeTab === 'completed'" class="space-y-4">
|
||||
<template x-for="todo in completedTodos" :key="todo.id">
|
||||
<div class="todo-card completed bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-gray-900 mb-3" x-text="todo.content"></p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="status-badge status-completed">완료</span>
|
||||
<span class="time-badge" x-text="`${todo.estimated_minutes}분`"></span>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(todo.completed_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div x-show="completedTodos.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-check text-6xl text-gray-300 mb-4"></i>
|
||||
<p class="text-gray-500">완료된 할일이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 일정 설정 모달 -->
|
||||
<div x-show="showScheduleModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 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">
|
||||
<h3 class="text-xl font-bold text-gray-900" x-text="activeTab === 'todo' ? '일정 지연' : '일정 설정'"></h3>
|
||||
<p class="text-sm text-gray-600 mt-1" x-text="activeTab === 'todo' ? '새로운 날짜와 시간을 설정하세요' : '날짜와 예상 소요시간을 설정하세요'"></p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">시작 날짜</label>
|
||||
<input
|
||||
type="date"
|
||||
x-model="scheduleForm.start_date"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">예상 소요시간</label>
|
||||
<select
|
||||
x-model="scheduleForm.estimated_minutes"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="15">15분</option>
|
||||
<option value="30">30분</option>
|
||||
<option value="60">1시간</option>
|
||||
<option value="90">1시간 30분</option>
|
||||
<option value="120">2시간</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
하루 8시간 초과 시 경고가 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="scheduleTodo(); hapticFeedback($event.target)"
|
||||
:disabled="!scheduleForm.start_date || !scheduleForm.estimated_minutes"
|
||||
class="flex-1 btn-primary px-4 py-2 text-white rounded-lg disabled:opacity-50"
|
||||
x-text="activeTab === 'todo' ? '지연 설정' : '일정 설정'"
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
@click="closeScheduleModal(); hapticFeedback($event.target)"
|
||||
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 분할 모달 -->
|
||||
<div x-show="showSplitModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-lg w-full">
|
||||
<div class="p-6 border-b">
|
||||
<h3 class="text-xl font-bold text-gray-900">할일 분할</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">2시간 이상의 작업을 작은 단위로 나누어 관리하세요</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-4 mb-6">
|
||||
<template x-for="(subtask, index) in splitForm.subtasks" :key="index">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
x-model="splitForm.subtasks[index]"
|
||||
:placeholder="`하위 작업 ${index + 1}`"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
</div>
|
||||
<select
|
||||
x-model="splitForm.estimated_minutes_per_task[index]"
|
||||
class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="1">1분</option>
|
||||
<option value="30">30분</option>
|
||||
<option value="60">1시간</option>
|
||||
</select>
|
||||
<button
|
||||
x-show="splitForm.subtasks.length > 2"
|
||||
@click="splitForm.subtasks.splice(index, 1); splitForm.estimated_minutes_per_task.splice(index, 1)"
|
||||
class="px-2 py-2 text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<button
|
||||
@click="splitForm.subtasks.push(''); splitForm.estimated_minutes_per_task.push(30)"
|
||||
class="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded-md hover:bg-gray-300"
|
||||
>
|
||||
<i class="fas fa-plus mr-1"></i>하위 작업 추가
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">최대 10개까지 가능</span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="splitTodo()"
|
||||
class="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
분할하기
|
||||
</button>
|
||||
<button
|
||||
@click="closeSplitModal()"
|
||||
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 댓글 모달 -->
|
||||
<div x-show="showCommentModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-lg w-full max-h-[80vh] overflow-y-auto">
|
||||
<div class="p-6 border-b">
|
||||
<h3 class="text-xl font-bold text-gray-900">메모 작성</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- 기존 댓글들 -->
|
||||
<div x-show="currentTodoComments.length > 0" class="mb-4 space-y-3">
|
||||
<template x-for="comment in currentTodoComments" :key="comment.id">
|
||||
<div class="comment-bubble">
|
||||
<p class="text-gray-900 text-sm" x-text="comment.content"></p>
|
||||
<p class="text-xs text-gray-500 mt-2" x-text="formatDate(comment.created_at)"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 새 댓글 입력 -->
|
||||
<div class="mb-4">
|
||||
<textarea
|
||||
x-model="commentForm.content"
|
||||
placeholder="메모를 입력하세요..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="addComment()"
|
||||
:disabled="!commentForm.content.trim()"
|
||||
class="flex-1 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
메모 추가
|
||||
</button>
|
||||
<button
|
||||
@click="closeCommentModal()"
|
||||
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 헤더 로더 -->
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
|
||||
<!-- API 및 앱 스크립트 -->
|
||||
<script src="static/js/api.js?v=2025012627"></script>
|
||||
<script src="static/js/todos.js?v=2025012627"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -38,7 +38,7 @@
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<main class="container mx-auto px-4 pt-20 pb-8">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
|
||||
520
frontend/user-management.html
Normal file
520
frontend/user-management.html
Normal file
@@ -0,0 +1,520 @@
|
||||
<!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="userManagementApp()">
|
||||
<!-- 헤더 -->
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<!-- 페이지 제목 -->
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">사용자 관리</h1>
|
||||
<p class="text-gray-600">시스템 사용자를 관리하고 권한을 설정하세요.</p>
|
||||
</div>
|
||||
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
<i class="fas fa-plus mr-2"></i>새 사용자 추가
|
||||
</button>
|
||||
</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">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-lg font-medium text-gray-900">사용자 목록</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
총 <span x-text="users.length"></span>명의 사용자
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 테이블 -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<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>
|
||||
<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="user in users" :key="user.id">
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center mr-4">
|
||||
<i class="fas fa-user text-blue-600"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900" x-text="user.full_name || user.email"></div>
|
||||
<div class="text-sm text-gray-500" x-text="user.email"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<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>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex space-x-1">
|
||||
<span x-show="user.can_manage_books" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
<i class="fas fa-book mr-1"></i>서적
|
||||
</span>
|
||||
<span x-show="user.can_manage_notes" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<i class="fas fa-sticky-note mr-1"></i>노트
|
||||
</span>
|
||||
<span x-show="user.can_manage_novels" class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<i class="fas fa-feather-alt mr-1"></i>소설
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="user.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'">
|
||||
<i :class="user.is_active ? 'fas fa-check-circle' : 'fas fa-times-circle'" class="mr-1"></i>
|
||||
<span x-text="user.is_active ? '활성' : '비활성'"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span x-text="formatDate(user.created_at)"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex space-x-2">
|
||||
<button @click="editUser(user)" class="text-blue-600 hover:text-blue-900">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button @click="confirmDeleteUser(user)"
|
||||
x-show="user.role !== 'root'"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 생성 모달 -->
|
||||
<div x-show="showCreateModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">새 사용자 추가</h3>
|
||||
|
||||
<form @submit.prevent="createUser()" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" x-model="createForm.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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||
<input type="password" x-model="createForm.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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input type="text" x-model="createForm.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 class="block text-sm font-medium text-gray-700 mb-1">역할</label>
|
||||
<select x-model="createForm.role"
|
||||
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="user">사용자</option>
|
||||
<option value="admin" x-show="currentUser.role === 'root'">관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">세션 타임아웃 (분)</label>
|
||||
<select x-model="createForm.session_timeout_minutes"
|
||||
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="5">5분 (기본)</option>
|
||||
<option value="15">15분</option>
|
||||
<option value="30">30분</option>
|
||||
<option value="60">1시간</option>
|
||||
<option value="120">2시간</option>
|
||||
<option value="480">8시간</option>
|
||||
<option value="1440">24시간</option>
|
||||
<option value="0">무제한</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">0 = 무제한 (로그아웃 없음)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">권한</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="createForm.can_manage_books" class="mr-2">
|
||||
<span class="text-sm">서적 관리</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="createForm.can_manage_notes" class="mr-2">
|
||||
<span class="text-sm">노트 관리</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="createForm.can_manage_novels" class="mr-2">
|
||||
<span class="text-sm">소설 관리</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" @click="showCreateModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" :disabled="createLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="createLoading"></i>
|
||||
<span x-text="createLoading ? '생성 중...' : '사용자 생성'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 수정 모달 -->
|
||||
<div x-show="showEditModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">사용자 수정</h3>
|
||||
|
||||
<form @submit.prevent="updateUser()" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input type="email" x-model="editForm.email" disabled
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input type="text" x-model="editForm.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 class="block text-sm font-medium text-gray-700 mb-1">역할</label>
|
||||
<select x-model="editForm.role"
|
||||
:disabled="editForm.role === 'root'"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50">
|
||||
<option value="user">사용자</option>
|
||||
<option value="admin" x-show="currentUser.role === 'root'">관리자</option>
|
||||
<option value="root" x-show="editForm.role === 'root'">시스템 관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center mb-2">
|
||||
<input type="checkbox" x-model="editForm.is_active" class="mr-2">
|
||||
<span class="text-sm font-medium text-gray-700">계정 활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">세션 타임아웃 (분)</label>
|
||||
<select x-model="editForm.session_timeout_minutes"
|
||||
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="5">5분 (기본)</option>
|
||||
<option value="15">15분</option>
|
||||
<option value="30">30분</option>
|
||||
<option value="60">1시간</option>
|
||||
<option value="120">2시간</option>
|
||||
<option value="480">8시간</option>
|
||||
<option value="1440">24시간</option>
|
||||
<option value="0">무제한</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">0 = 무제한 (로그아웃 없음)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">권한</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="editForm.can_manage_books" class="mr-2">
|
||||
<span class="text-sm">서적 관리</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="editForm.can_manage_notes" class="mr-2">
|
||||
<span class="text-sm">노트 관리</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" x-model="editForm.can_manage_novels" class="mr-2">
|
||||
<span class="text-sm">소설 관리</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" @click="showEditModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" :disabled="editLoading"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="editLoading"></i>
|
||||
<span x-text="editLoading ? '수정 중...' : '사용자 수정'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 확인 모달 -->
|
||||
<div x-show="showDeleteModal" x-transition class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3 text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">사용자 삭제</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
<span x-text="deleteTarget?.full_name || deleteTarget?.email"></span> 사용자를 삭제하시겠습니까?<br>
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center space-x-3">
|
||||
<button @click="showDeleteModal = false"
|
||||
class="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
취소
|
||||
</button>
|
||||
<button @click="deleteUser()" :disabled="deleteLoading"
|
||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
<i class="fas fa-spinner fa-spin mr-2" x-show="deleteLoading"></i>
|
||||
<span x-text="deleteLoading ? '삭제 중...' : '삭제'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공통 스크립트 -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/header-loader.js"></script>
|
||||
|
||||
<!-- 사용자 관리 스크립트 -->
|
||||
<script>
|
||||
function userManagementApp() {
|
||||
return {
|
||||
users: [],
|
||||
currentUser: {},
|
||||
showCreateModal: false,
|
||||
showEditModal: false,
|
||||
showDeleteModal: false,
|
||||
createLoading: false,
|
||||
editLoading: false,
|
||||
deleteLoading: false,
|
||||
deleteTarget: null,
|
||||
|
||||
createForm: {
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user',
|
||||
can_manage_books: true,
|
||||
can_manage_notes: true,
|
||||
can_manage_novels: true,
|
||||
session_timeout_minutes: 5
|
||||
},
|
||||
|
||||
editForm: {},
|
||||
|
||||
notification: {
|
||||
show: false,
|
||||
type: 'success',
|
||||
message: ''
|
||||
},
|
||||
|
||||
async init() {
|
||||
console.log('🔧 사용자 관리 앱 초기화');
|
||||
await this.loadCurrentUser();
|
||||
await this.loadUsers();
|
||||
},
|
||||
|
||||
async loadCurrentUser() {
|
||||
try {
|
||||
this.currentUser = await api.get('/users/me');
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (!this.currentUser.is_admin) {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 헤더 사용자 메뉴 업데이트
|
||||
if (window.updateUserMenu) {
|
||||
window.updateUserMenu(this.currentUser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 현재 사용자 정보 로드 실패:', error);
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
},
|
||||
|
||||
async loadUsers() {
|
||||
try {
|
||||
this.users = await api.get('/users/');
|
||||
console.log('✅ 사용자 목록 로드 완료:', this.users.length, '명');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 목록 로드 실패:', error);
|
||||
this.showNotification('사용자 목록을 불러올 수 없습니다.', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createUser() {
|
||||
this.createLoading = true;
|
||||
try {
|
||||
await api.post('/users/', this.createForm);
|
||||
|
||||
// 폼 초기화
|
||||
this.createForm = {
|
||||
email: '',
|
||||
password: '',
|
||||
full_name: '',
|
||||
role: 'user',
|
||||
can_manage_books: true,
|
||||
can_manage_notes: true,
|
||||
can_manage_novels: true,
|
||||
session_timeout_minutes: 5
|
||||
};
|
||||
|
||||
this.showCreateModal = false;
|
||||
await this.loadUsers();
|
||||
this.showNotification('새 사용자가 성공적으로 생성되었습니다.', 'success');
|
||||
console.log('✅ 사용자 생성 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 생성 실패:', error);
|
||||
this.showNotification('사용자 생성에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.createLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
editUser(user) {
|
||||
this.editForm = { ...user };
|
||||
this.showEditModal = true;
|
||||
},
|
||||
|
||||
async updateUser() {
|
||||
this.editLoading = true;
|
||||
try {
|
||||
await api.put(`/users/${this.editForm.id}`, {
|
||||
full_name: this.editForm.full_name,
|
||||
role: this.editForm.role,
|
||||
is_active: this.editForm.is_active,
|
||||
can_manage_books: this.editForm.can_manage_books,
|
||||
can_manage_notes: this.editForm.can_manage_notes,
|
||||
can_manage_novels: this.editForm.can_manage_novels,
|
||||
session_timeout_minutes: this.editForm.session_timeout_minutes
|
||||
});
|
||||
|
||||
this.showEditModal = false;
|
||||
await this.loadUsers();
|
||||
this.showNotification('사용자 정보가 성공적으로 수정되었습니다.', 'success');
|
||||
console.log('✅ 사용자 수정 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 수정 실패:', error);
|
||||
this.showNotification('사용자 수정에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.editLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDeleteUser(user) {
|
||||
this.deleteTarget = user;
|
||||
this.showDeleteModal = true;
|
||||
},
|
||||
|
||||
async deleteUser() {
|
||||
this.deleteLoading = true;
|
||||
try {
|
||||
await api.delete(`/users/${this.deleteTarget.id}`);
|
||||
|
||||
this.showDeleteModal = false;
|
||||
this.deleteTarget = null;
|
||||
await this.loadUsers();
|
||||
this.showNotification('사용자가 성공적으로 삭제되었습니다.', 'success');
|
||||
console.log('✅ 사용자 삭제 완료');
|
||||
} catch (error) {
|
||||
console.error('❌ 사용자 삭제 실패:', error);
|
||||
this.showNotification('사용자 삭제에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
this.deleteLoading = 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] || '사용자';
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR');
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -266,13 +266,210 @@
|
||||
</div>
|
||||
|
||||
<!-- 문서 내용 -->
|
||||
<div x-show="!loading && !error" id="document-content" class="prose max-w-none">
|
||||
<!-- 문서 HTML이 여기에 로드됩니다 -->
|
||||
<div x-show="!loading && !error">
|
||||
<!-- HTML 문서 내용 -->
|
||||
<div x-show="contentType === 'document' && document && !document.pdf_path"
|
||||
id="document-content" class="prose max-w-none">
|
||||
<!-- 문서 HTML이 여기에 로드됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- PDF 뷰어 -->
|
||||
<div x-show="contentType === 'document' && document && document.pdf_path"
|
||||
class="pdf-viewer-container">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<i class="fas fa-file-pdf text-red-600 text-xl"></i>
|
||||
<h1 class="text-2xl font-bold text-gray-900" x-text="document?.title"></h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="openPdfSearchModal()"
|
||||
class="px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2">
|
||||
<i class="fas fa-search"></i>
|
||||
<span>PDF에서 검색</span>
|
||||
</button>
|
||||
<button @click="downloadOriginalFile()"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 뷰어 컨테이너 -->
|
||||
<div class="border rounded-lg overflow-hidden bg-white relative" style="min-height: 800px;">
|
||||
<!-- PDF.js 뷰어 -->
|
||||
<div x-show="!pdfError && !pdfLoading && pdfSrc" class="w-full h-full">
|
||||
<!-- PDF 뷰어 툴바 -->
|
||||
<div class="bg-gray-100 border-b px-4 py-2 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="previousPage()" :disabled="currentPage <= 1"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded disabled:bg-gray-300">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<span class="text-sm">
|
||||
<input type="number" x-model="currentPage" @change="goToPage(currentPage)"
|
||||
class="w-16 px-2 py-1 border rounded text-center" min="1" :max="totalPages">
|
||||
/ <span x-text="totalPages"></span>
|
||||
</span>
|
||||
<button @click="nextPage()" :disabled="currentPage >= totalPages"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded disabled:bg-gray-300">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button @click="zoomOut()" class="px-2 py-1 bg-gray-600 text-white rounded">
|
||||
<i class="fas fa-search-minus"></i>
|
||||
</button>
|
||||
<span class="text-sm" x-text="Math.round(pdfScale * 100) + '%'"></span>
|
||||
<button @click="zoomIn()" class="px-2 py-1 bg-gray-600 text-white rounded">
|
||||
<i class="fas fa-search-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF 캔버스 -->
|
||||
<div class="overflow-auto" style="height: 750px;">
|
||||
<canvas id="pdf-canvas" class="mx-auto block"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 iframe (폴백용) -->
|
||||
<iframe id="pdf-viewer-iframe"
|
||||
x-show="false"
|
||||
class="w-full border-0"
|
||||
style="height: 800px;"
|
||||
:src="pdfSrc"
|
||||
@load="pdfLoaded = true; console.log('PDF iframe 로드 완료')"
|
||||
@error="handlePdfError()"
|
||||
allow="fullscreen">
|
||||
</iframe>
|
||||
|
||||
<!-- PDF 로딩 상태 -->
|
||||
<div x-show="pdfLoading" class="flex items-center justify-center h-full" style="min-height: 400px;">
|
||||
<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="pdfError" class="flex items-center justify-center h-full text-gray-500" style="min-height: 400px;">
|
||||
<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="retryPdfLoad()"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 mr-2">
|
||||
다시 시도
|
||||
</button>
|
||||
<button @click="downloadOriginalFile()"
|
||||
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
파일 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 노트 문서 내용 -->
|
||||
<div x-show="contentType === 'note'"
|
||||
id="note-content" class="prose max-w-none">
|
||||
<!-- 노트 내용이 여기에 로드됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- PDF 검색 모달 -->
|
||||
<div x-show="showPdfSearchModal"
|
||||
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="showPdfSearchModal = false">
|
||||
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
|
||||
<!-- 헤더 -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h3 class="text-xl font-bold text-gray-900 flex items-center space-x-2">
|
||||
<i class="fas fa-search text-green-600"></i>
|
||||
<span>PDF에서 검색</span>
|
||||
</h3>
|
||||
<button @click="showPdfSearchModal = false"
|
||||
class="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">검색어</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
x-model="pdfSearchQuery"
|
||||
@keydown.enter="searchInPdf()"
|
||||
@input="pdfSearchResults = []"
|
||||
placeholder="예: pressure, vessel, design..."
|
||||
class="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
x-ref="searchInput">
|
||||
<div class="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<i class="fas fa-search text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
Enter 키를 누르거나 검색 버튼을 클릭하세요
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 -->
|
||||
<div x-show="pdfSearchResults.length > 0" class="mb-4">
|
||||
<div class="text-sm text-gray-600 mb-2">
|
||||
<i class="fas fa-check-circle text-green-500 mr-1"></i>
|
||||
<span x-text="pdfSearchResults.length"></span>개의 결과를 찾았습니다.
|
||||
</div>
|
||||
<div class="max-h-40 overflow-y-auto space-y-2">
|
||||
<template x-for="(result, index) in pdfSearchResults" :key="index">
|
||||
<div class="p-3 bg-gray-50 rounded cursor-pointer hover:bg-green-50 border hover:border-green-200 transition-colors"
|
||||
@click="jumpToPdfResult(result)">
|
||||
<div class="text-sm font-medium text-green-700">
|
||||
<i class="fas fa-file-pdf mr-1"></i>
|
||||
페이지 <span x-text="result.page"></span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 mt-1" x-text="result.context"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 검색 결과 없음 -->
|
||||
<div x-show="pdfSearchQuery.trim() && !pdfSearchLoading && pdfSearchResults.length === 0" class="mb-4">
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<i class="fas fa-search text-2xl mb-2"></i>
|
||||
<p class="text-sm">검색 결과가 없습니다.</p>
|
||||
<p class="text-xs mt-1">다른 검색어로 시도해보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex space-x-3">
|
||||
<button @click="searchInPdf()"
|
||||
:disabled="!pdfSearchQuery.trim() || pdfSearchLoading"
|
||||
class="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
|
||||
<i :class="pdfSearchLoading ? 'fas fa-spinner fa-spin' : 'fas fa-search'" class="mr-2"></i>
|
||||
<span x-text="pdfSearchLoading ? '검색 중...' : '검색'"></span>
|
||||
</button>
|
||||
<button @click="showPdfSearchModal = false"
|
||||
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 링크 모달 -->
|
||||
<div x-show="showLinksModal"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
@@ -311,29 +508,45 @@
|
||||
<template x-for="link in documentLinks" :key="link.id">
|
||||
<div class="border rounded-lg p-4 mb-3 hover:bg-purple-50 cursor-pointer transition-colors"
|
||||
@click="navigateToLink(link)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-purple-700 mb-1" x-text="link.target_document_title"></div>
|
||||
|
||||
<!-- 선택된 텍스트 또는 문서 전체 링크 -->
|
||||
<div x-show="link.selected_text" class="mb-2">
|
||||
<div class="text-sm text-gray-600 bg-gray-100 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
|
||||
<!-- 대상 문서 제목 -->
|
||||
<div class="font-medium text-purple-700 mb-2 flex items-center">
|
||||
<span x-text="link.target_document_title || link.target_note_title"></span>
|
||||
<span x-show="link.target_content_type === 'note'" class="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">노트</span>
|
||||
<span x-show="link.target_content_type === 'document'" class="ml-2 text-xs bg-green-100 text-green-700 px-2 py-1 rounded">문서</span>
|
||||
</div>
|
||||
<div x-show="!link.selected_text" class="mb-2">
|
||||
<div class="text-sm text-gray-600 italic">📄 문서 전체 링크</div>
|
||||
|
||||
<!-- 현재 문서에서 선택한 텍스트 (출발점) -->
|
||||
<div x-show="link.selected_text" class="mb-3">
|
||||
<div class="text-xs text-gray-500 mb-1">📍 현재 문서에서 선택한 텍스트:</div>
|
||||
<div class="text-sm text-gray-700 bg-purple-50 px-3 py-2 rounded border-l-4 border-purple-500" x-text="link.selected_text"></div>
|
||||
</div>
|
||||
|
||||
<!-- 대상 문서의 텍스트 (도착점) -->
|
||||
<div x-show="link.target_text" class="mb-3">
|
||||
<div class="text-xs text-gray-500 mb-1">🎯 대상 문서의 텍스트:</div>
|
||||
<div class="text-sm text-gray-700 bg-blue-50 px-3 py-2 rounded border-l-4 border-blue-500" x-text="link.target_text"></div>
|
||||
</div>
|
||||
|
||||
<!-- 문서 전체 링크인 경우 -->
|
||||
<div x-show="!link.selected_text && !link.target_text" class="mb-3">
|
||||
<div class="text-sm text-gray-600 italic bg-gray-50 px-3 py-2 rounded">📄 문서 전체 링크</div>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div x-show="link.description" class="text-sm text-gray-600 mb-2" x-text="link.description"></div>
|
||||
<div x-show="link.description" class="mb-3">
|
||||
<div class="text-xs text-gray-500 mb-1">💬 설명:</div>
|
||||
<div class="text-sm text-gray-600 bg-yellow-50 px-3 py-2 rounded" x-text="link.description"></div>
|
||||
</div>
|
||||
|
||||
<!-- 링크 타입과 날짜 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500"
|
||||
x-text="link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'"></span>
|
||||
<span class="text-xs text-gray-500" x-text="formatDate(link.created_at)"></span>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span x-text="link.link_type === 'text_fragment' ? '🔗 텍스트 조각 링크' : '📄 문서 링크'"></span>
|
||||
<span x-text="formatDate(link.created_at)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<div class="ml-3 flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
||||
</svg>
|
||||
@@ -376,15 +589,39 @@
|
||||
<p class="text-purple-700" x-text="linkForm?.selected_text || ''"></p>
|
||||
</div>
|
||||
|
||||
<!-- 서적 선택 -->
|
||||
<!-- 링크 대상 타입 선택 -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">서적 선택</label>
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3">링크 대상 타입</label>
|
||||
<div class="flex space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="radio"
|
||||
x-model="linkForm.target_type"
|
||||
value="document"
|
||||
@change="onTargetTypeChange()"
|
||||
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="linkForm.target_type"
|
||||
value="note"
|
||||
@change="onTargetTypeChange()"
|
||||
class="mr-2 text-blue-600">
|
||||
<span class="text-sm text-gray-700">📝 노트북 노트</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 서적/노트북 선택 -->
|
||||
<div class="mb-6" x-show="linkForm.target_type">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-3"
|
||||
x-text="linkForm.target_type === 'note' ? '노트북 선택' : '서적 선택'"></label>
|
||||
<select
|
||||
x-model="linkForm.target_book_id"
|
||||
@change="loadDocumentsFromBook()"
|
||||
class="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
>
|
||||
<option value="">서적을 선택하세요</option>
|
||||
<option value="" x-text="linkForm.target_type === 'note' ? '노트북을 선택하세요' : '서적을 선택하세요'"></option>
|
||||
<template x-for="book in availableBooks" :key="book.id">
|
||||
<option :value="book.id" x-text="book.title"></option>
|
||||
</template>
|
||||
@@ -404,8 +641,10 @@
|
||||
:disabled="!linkForm.target_book_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:bg-gray-100 disabled:cursor-not-allowed">
|
||||
<option value="">
|
||||
<span x-show="!linkForm.target_book_id">먼저 서적을 선택하세요</span>
|
||||
<span x-show="linkForm.target_book_id">문서를 선택하세요</span>
|
||||
<span x-show="!linkForm.target_book_id"
|
||||
x-text="linkForm.target_type === 'note' ? '먼저 노트북을 선택하세요' : '먼저 서적을 선택하세요'"></span>
|
||||
<span x-show="linkForm.target_book_id"
|
||||
x-text="linkForm.target_type === 'note' ? '노트를 선택하세요' : '문서를 선택하세요'"></span>
|
||||
</option>
|
||||
<template x-for="doc in filteredDocuments" :key="doc.id">
|
||||
<option :value="doc.id" x-text="doc.title"></option>
|
||||
@@ -501,8 +740,8 @@
|
||||
<div class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
@click="scrollToHighlight(note.highlight.id)">
|
||||
<!-- 선택된 텍스트 -->
|
||||
<div class="bg-blue-50 rounded-md p-2 mb-3">
|
||||
<p class="text-sm text-blue-800 font-medium" x-text="note.highlight.selected_text"></p>
|
||||
<div class="bg-blue-50 rounded-md p-2 mb-3" x-show="note.highlight?.selected_text">
|
||||
<p class="text-sm text-blue-800 font-medium" x-text="note.highlight?.selected_text || ''"></p>
|
||||
</div>
|
||||
|
||||
<!-- 메모 내용 -->
|
||||
@@ -731,22 +970,25 @@
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script src="/static/js/api.js?v=2025012614"></script>
|
||||
<script src="/static/js/api.js?v=2025012618"></script>
|
||||
|
||||
<!-- 캐시 및 성능 최적화 시스템 -->
|
||||
<script src="/static/js/viewer/utils/cache-manager.js?v=2025012607"></script>
|
||||
<script src="/static/js/viewer/utils/cached-api.js?v=2025012607"></script>
|
||||
<script src="/static/js/viewer/utils/cached-api.js?v=2025012618"></script>
|
||||
<script src="/static/js/viewer/utils/module-loader.js?v=2025012607"></script>
|
||||
|
||||
<!-- 모든 모듈들 직접 로드 -->
|
||||
<script src="/static/js/viewer/core/document-loader.js?v=2025012607"></script>
|
||||
<script src="/static/js/viewer/core/document-loader.js?v=2025012624"></script>
|
||||
<script src="/static/js/viewer/features/ui-manager.js?v=2025012607"></script>
|
||||
<script src="/static/js/viewer/features/highlight-manager.js?v=2025012607"></script>
|
||||
<script src="/static/js/viewer/features/link-manager.js?v=2025012607"></script>
|
||||
<script src="/static/js/viewer/features/highlight-manager.js?v=2025012619"></script>
|
||||
<script src="/static/js/viewer/features/link-manager.js?v=2025012622"></script>
|
||||
<script src="/static/js/viewer/features/bookmark-manager.js?v=2025012607"></script>
|
||||
|
||||
<!-- ViewerCore (Alpine.js 컴포넌트) -->
|
||||
<script src="/static/js/viewer/viewer-core.js?v=2025012607"></script>
|
||||
<script src="/static/js/viewer/viewer-core.js?v=2025012626"></script>
|
||||
|
||||
<!-- PDF.js 라이브러리 -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
|
||||
<!-- Alpine.js 프레임워크 -->
|
||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
@@ -11,6 +11,36 @@ server {
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# PDF 파일 요청 (iframe 허용)
|
||||
location ~ ^/api/documents/[^/]+/pdf$ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 타임아웃 설정
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
|
||||
# 버퍼링 설정
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
|
||||
# 리다이렉트 방지
|
||||
proxy_redirect off;
|
||||
|
||||
# PDF iframe 허용 및 인라인 표시 설정
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
|
||||
# PDF 파일이 다운로드되지 않고 브라우저에서 표시되도록 설정
|
||||
location ~ \.pdf$ {
|
||||
add_header Content-Disposition "inline";
|
||||
}
|
||||
}
|
||||
|
||||
# API 요청을 백엔드로 프록시
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
@@ -28,6 +58,24 @@ server {
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
|
||||
# 리다이렉트 방지
|
||||
proxy_redirect off;
|
||||
|
||||
# CORS 헤더 추가
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
|
||||
# OPTIONS 요청 처리
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type text/plain;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# 업로드된 문서 파일 서빙
|
||||
|
||||
@@ -45,8 +45,7 @@ http {
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# 보안 헤더
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
# 보안 헤더 (PDF 파일은 제외)
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
|
||||
33
scripts/backup.sh
Executable file
33
scripts/backup.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Document Server 데이터베이스 백업 스크립트
|
||||
# 시놀로지 NAS 환경에서 사용
|
||||
|
||||
BACKUP_DIR="/volume1/docker/document-server/backups"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
CONTAINER_NAME="document-server-db"
|
||||
|
||||
# 백업 디렉토리 생성
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "🔄 데이터베이스 백업 시작: $TIMESTAMP"
|
||||
|
||||
# PostgreSQL 백업
|
||||
docker exec $CONTAINER_NAME pg_dump -U docuser -d document_db > "$BACKUP_DIR/document_db_$TIMESTAMP.sql"
|
||||
|
||||
# 압축
|
||||
gzip "$BACKUP_DIR/document_db_$TIMESTAMP.sql"
|
||||
|
||||
# 7일 이상 된 백업 파일 삭제
|
||||
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete
|
||||
|
||||
echo "✅ 백업 완료: $BACKUP_DIR/document_db_$TIMESTAMP.sql.gz"
|
||||
|
||||
# 업로드 파일 백업 (선택사항)
|
||||
if [ "$1" = "--include-uploads" ]; then
|
||||
echo "🔄 업로드 파일 백업 시작..."
|
||||
tar -czf "$BACKUP_DIR/uploads_$TIMESTAMP.tar.gz" -C /volume1/docker/document-server uploads/
|
||||
echo "✅ 업로드 파일 백업 완료: $BACKUP_DIR/uploads_$TIMESTAMP.tar.gz"
|
||||
fi
|
||||
|
||||
echo "🎉 전체 백업 작업 완료"
|
||||
198
scripts/cleanup-for-production.sh
Executable file
198
scripts/cleanup-for-production.sh
Executable file
@@ -0,0 +1,198 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# Document Server - 프로덕션 배포용 정리 스크립트
|
||||
# 테스트 파일, 개발용 데이터, 로그 파일 등을 정리합니다
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 색상 정의
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
# 환경 설정
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
echo "=== 🧹 프로덕션 배포용 정리 시작 ==="
|
||||
echo ""
|
||||
|
||||
# 1. 테스트 파일 제거
|
||||
log_info "🗑️ 테스트 파일 제거 중..."
|
||||
|
||||
# 테스트 HTML 파일들
|
||||
TEST_FILES=(
|
||||
"test-document.html"
|
||||
"test-upload.html"
|
||||
"test.html"
|
||||
"cache-buster.html"
|
||||
"frontend/test-upload.html"
|
||||
"frontend/test.html"
|
||||
)
|
||||
|
||||
for file in "${TEST_FILES[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
rm "$file"
|
||||
log_success "제거: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# 2. 개발용 이미지 파일 제거
|
||||
log_info "🖼️ 테스트 이미지 파일 제거 중..."
|
||||
|
||||
# RAF 이미지 파일들 (테스트용)
|
||||
find . -name "*.RAF_compressed.JPEG" -delete 2>/dev/null || true
|
||||
find . -name "*.RAF" -delete 2>/dev/null || true
|
||||
|
||||
log_success "테스트 이미지 파일 제거 완료"
|
||||
|
||||
# 3. 로그 파일 정리
|
||||
log_info "📝 로그 파일 정리 중..."
|
||||
|
||||
LOG_FILES=(
|
||||
"backend.log"
|
||||
"frontend.log"
|
||||
)
|
||||
|
||||
for file in "${LOG_FILES[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
rm "$file"
|
||||
log_success "제거: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Python 캐시 파일 정리
|
||||
log_info "🐍 Python 캐시 파일 정리 중..."
|
||||
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -name "*.pyc" -delete 2>/dev/null || true
|
||||
find . -name "*.pyo" -delete 2>/dev/null || true
|
||||
|
||||
log_success "Python 캐시 파일 정리 완료"
|
||||
|
||||
# 5. 개발용 업로드 파일 정리
|
||||
log_info "📁 개발용 업로드 파일 정리 중..."
|
||||
|
||||
# 백엔드 uploads 디렉토리
|
||||
if [ -d "backend/uploads" ]; then
|
||||
rm -rf backend/uploads/documents/* 2>/dev/null || true
|
||||
rm -rf backend/uploads/thumbnails/* 2>/dev/null || true
|
||||
log_success "백엔드 업로드 파일 정리 완료"
|
||||
fi
|
||||
|
||||
# 프론트엔드 uploads 디렉토리
|
||||
if [ -d "frontend/uploads" ]; then
|
||||
rm -rf frontend/uploads/* 2>/dev/null || true
|
||||
log_success "프론트엔드 업로드 파일 정리 완료"
|
||||
fi
|
||||
|
||||
# 루트 uploads 디렉토리
|
||||
if [ -d "uploads" ]; then
|
||||
rm -rf uploads/documents/* 2>/dev/null || true
|
||||
rm -rf uploads/pdfs/* 2>/dev/null || true
|
||||
rm -rf uploads/thumbnails/* 2>/dev/null || true
|
||||
log_success "루트 업로드 파일 정리 완료"
|
||||
fi
|
||||
|
||||
# 6. 가상환경 제거 (프로덕션에서는 Docker 사용)
|
||||
log_info "🐍 가상환경 제거 중..."
|
||||
|
||||
if [ -d "backend/venv" ]; then
|
||||
rm -rf backend/venv
|
||||
log_success "가상환경 제거 완료"
|
||||
fi
|
||||
|
||||
# 7. 개발용 설정 파일 정리
|
||||
log_info "⚙️ 개발용 설정 파일 정리 중..."
|
||||
|
||||
# 환경 변수 파일들 (프로덕션에서 새로 생성)
|
||||
DEV_CONFIG_FILES=(
|
||||
".env"
|
||||
".env.local"
|
||||
".env.development"
|
||||
"backend/.env"
|
||||
)
|
||||
|
||||
for file in "${DEV_CONFIG_FILES[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
rm "$file"
|
||||
log_success "제거: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# 8. 불필요한 문서 파일 정리
|
||||
log_info "📚 개발용 문서 파일 정리 중..."
|
||||
|
||||
DEV_DOCS=(
|
||||
"VIEWER_REFACTORING.md"
|
||||
)
|
||||
|
||||
for file in "${DEV_DOCS[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
rm "$file"
|
||||
log_success "제거: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# 9. 빈 디렉토리 정리
|
||||
log_info "📂 빈 디렉토리 정리 중..."
|
||||
|
||||
find . -type d -empty -delete 2>/dev/null || true
|
||||
|
||||
# 10. 권한 정리
|
||||
log_info "🔐 파일 권한 정리 중..."
|
||||
|
||||
# 스크립트 파일 실행 권한 확인
|
||||
chmod +x scripts/*.sh 2>/dev/null || true
|
||||
|
||||
# 설정 파일 권한 설정
|
||||
find . -name "*.conf" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
find . -name "*.yml" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
find . -name "*.yaml" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
|
||||
log_success "파일 권한 정리 완료"
|
||||
|
||||
# 11. 정리 결과 요약
|
||||
echo ""
|
||||
echo "=== 📊 정리 결과 ==="
|
||||
|
||||
# 현재 디렉토리 크기
|
||||
TOTAL_SIZE=$(du -sh . | cut -f1)
|
||||
echo "전체 크기: $TOTAL_SIZE"
|
||||
|
||||
# 주요 디렉토리 크기
|
||||
echo ""
|
||||
echo "주요 디렉토리:"
|
||||
du -sh backend frontend scripts nginx 2>/dev/null | while read size dir; do
|
||||
echo " $dir: $size"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== ✅ 정리 완료 ==="
|
||||
echo ""
|
||||
echo "🚀 이제 프로덕션 배포 준비가 완료되었습니다!"
|
||||
echo ""
|
||||
echo "다음 단계:"
|
||||
echo "1. NAS에 업로드"
|
||||
echo "2. ./scripts/deploy-synology.sh 실행"
|
||||
echo "3. 배포 완료!"
|
||||
|
||||
log_success "프로덕션 정리 스크립트 완료"
|
||||
399
scripts/deploy-synology.sh
Executable file
399
scripts/deploy-synology.sh
Executable file
@@ -0,0 +1,399 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# Document Server - Synology DS1525+ 최적화 배포 스크립트
|
||||
#
|
||||
# 하드웨어 사양:
|
||||
# - CPU: AMD Ryzen R1600 (4코어/8스레드)
|
||||
# - RAM: 32GB DDR4 ECC
|
||||
# - SSD: 읽기/쓰기 캐시 활성화
|
||||
# - Storage: Volume1(SSD), Volume2(HDD)
|
||||
# =============================================================================
|
||||
|
||||
set -e # 에러 발생 시 스크립트 중단
|
||||
|
||||
# 색상 정의
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 로그 함수
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 환경 변수 설정
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
COMPOSE_FILE="docker-compose.synology.yml"
|
||||
|
||||
# 환경 변수 설정 확인
|
||||
ENV_FILE="$PROJECT_DIR/.env.synology"
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
log_info "환경 변수 파일이 없습니다. 설정을 시작합니다..."
|
||||
"$SCRIPT_DIR/setup-env.sh"
|
||||
fi
|
||||
|
||||
# 환경 변수 로드
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
log_info "환경 변수 파일을 로드합니다: $ENV_FILE"
|
||||
set -a # 자동으로 export
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
else
|
||||
log_error "환경 변수 파일을 찾을 수 없습니다"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "🚀 Synology DS1525+ 최적화 배포 시작"
|
||||
log_info "📁 프로젝트 디렉토리: $PROJECT_DIR"
|
||||
|
||||
# 1. 시스템 요구사항 확인
|
||||
log_info "🔍 시스템 요구사항 확인 중..."
|
||||
|
||||
# Docker 및 Docker Compose 확인
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
log_error "Docker Compose가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 메모리 확인 (최소 16GB 권장)
|
||||
TOTAL_MEM=$(free -g | awk '/^Mem:/{print $2}')
|
||||
if [ "$TOTAL_MEM" -lt 16 ]; then
|
||||
log_warning "메모리가 ${TOTAL_MEM}GB입니다. 최소 16GB를 권장합니다."
|
||||
fi
|
||||
|
||||
log_success "시스템 요구사항 확인 완료"
|
||||
|
||||
# 2. 디렉토리 구조 생성
|
||||
log_info "📂 디렉토리 구조 생성 중..."
|
||||
|
||||
# SSD 디렉토리 (성능 최우선) - Volume3
|
||||
SSD_DIRS=(
|
||||
"/volume3/docker/document-server/database"
|
||||
"/volume3/docker/document-server/redis"
|
||||
"/volume3/docker/document-server/logs"
|
||||
"/volume3/docker/document-server/logs/nginx"
|
||||
"/volume3/docker/document-server/config"
|
||||
"/volume3/docker/document-server/nginx/conf.d"
|
||||
"/volume3/docker/document-server/nginx/cache"
|
||||
"/volume3/docker/document-server/cache"
|
||||
)
|
||||
|
||||
# HDD 디렉토리 (대용량 저장) - Volume1
|
||||
HDD_DIRS=(
|
||||
"/volume1/document-storage/uploads"
|
||||
"/volume1/document-storage/documents"
|
||||
"/volume1/document-storage/thumbnails"
|
||||
"/volume1/document-storage/backups"
|
||||
"/volume1/document-storage/archives"
|
||||
)
|
||||
|
||||
# SSD 디렉토리 생성
|
||||
for dir in "${SSD_DIRS[@]}"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
sudo mkdir -p "$dir"
|
||||
log_info "SSD 디렉토리 생성: $dir"
|
||||
fi
|
||||
done
|
||||
|
||||
# HDD 디렉토리 생성
|
||||
for dir in "${HDD_DIRS[@]}"; do
|
||||
if [ ! -d "$dir" ]; then
|
||||
sudo mkdir -p "$dir"
|
||||
log_info "HDD 디렉토리 생성: $dir"
|
||||
fi
|
||||
done
|
||||
|
||||
# 권한 설정
|
||||
sudo chown -R 1000:1000 /volume3/docker/document-server/
|
||||
sudo chown -R 1000:1000 /volume1/document-storage/
|
||||
|
||||
log_success "디렉토리 구조 생성 완료"
|
||||
|
||||
# 3. 설정 파일 복사
|
||||
log_info "⚙️ 설정 파일 생성 중..."
|
||||
|
||||
# PostgreSQL 설정 (32GB RAM 최적화)
|
||||
cat > /volume3/docker/document-server/config/postgresql.synology.conf << 'EOF'
|
||||
# PostgreSQL 설정 - Synology DS1525+ 32GB RAM 최적화
|
||||
|
||||
# 메모리 설정 (32GB RAM 기준)
|
||||
shared_buffers = 8GB # RAM의 25%
|
||||
effective_cache_size = 24GB # RAM의 75%
|
||||
work_mem = 512MB # 복잡한 쿼리용 (증가)
|
||||
maintenance_work_mem = 4GB # 인덱스 구축용 (증가)
|
||||
|
||||
# 체크포인트 설정 (SSD 최적화)
|
||||
checkpoint_completion_target = 0.9
|
||||
wal_buffers = 128MB # WAL 버퍼 (증가)
|
||||
checkpoint_timeout = 15min
|
||||
max_wal_size = 4GB
|
||||
min_wal_size = 1GB
|
||||
|
||||
# SSD 최적화
|
||||
random_page_cost = 1.1 # SSD 환경
|
||||
effective_io_concurrency = 200 # SSD 동시 I/O
|
||||
seq_page_cost = 1.0
|
||||
|
||||
# 병렬 처리 (4코어/8스레드 최적화)
|
||||
max_worker_processes = 8
|
||||
max_parallel_workers_per_gather = 4
|
||||
max_parallel_workers = 8
|
||||
max_parallel_maintenance_workers = 4
|
||||
|
||||
# 연결 설정
|
||||
max_connections = 200
|
||||
shared_preload_libraries = 'pg_stat_statements'
|
||||
|
||||
# 로깅 설정
|
||||
log_min_duration_statement = 1000 # 1초 이상 쿼리 로깅
|
||||
log_checkpoints = on
|
||||
log_connections = on
|
||||
log_disconnections = on
|
||||
log_lock_waits = on
|
||||
|
||||
# 자동 VACUUM 설정
|
||||
autovacuum = on
|
||||
autovacuum_max_workers = 4
|
||||
autovacuum_naptime = 30s
|
||||
EOF
|
||||
|
||||
# Nginx 설정 (SSD 캐시 최적화)
|
||||
cat > /volume3/docker/document-server/nginx/conf.d/default.conf << 'EOF'
|
||||
# Nginx 설정 - SSD 캐시 최적화
|
||||
|
||||
# 업스트림 백엔드
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# 캐시 존 정의 (SSD에 저장)
|
||||
proxy_cache_path /var/cache/nginx/documents
|
||||
levels=1:2
|
||||
keys_zone=documents:100m
|
||||
max_size=2g
|
||||
inactive=60m
|
||||
use_temp_path=off;
|
||||
|
||||
proxy_cache_path /var/cache/nginx/api
|
||||
levels=1:2
|
||||
keys_zone=api:50m
|
||||
max_size=500m
|
||||
inactive=10m
|
||||
use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# 클라이언트 설정
|
||||
client_max_body_size 500M;
|
||||
client_body_timeout 300s;
|
||||
client_header_timeout 300s;
|
||||
|
||||
# Gzip 압축
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/json;
|
||||
|
||||
# 정적 파일 (SSD에서 서빙)
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# 정적 파일 캐시
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# 업로드된 문서 (HDD에서 서빙, SSD 캐시)
|
||||
location /uploads/ {
|
||||
alias /usr/share/nginx/html/uploads/;
|
||||
|
||||
# 문서 캐시 (자주 접근하는 문서는 SSD에 캐시)
|
||||
proxy_cache documents;
|
||||
proxy_cache_valid 200 60m;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
|
||||
expires 1h;
|
||||
}
|
||||
|
||||
# API 요청
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# API 응답 캐시 (GET 요청만)
|
||||
proxy_cache api;
|
||||
proxy_cache_methods GET HEAD;
|
||||
proxy_cache_valid 200 5m;
|
||||
proxy_cache_bypass $http_pragma $http_authorization;
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
|
||||
# 타임아웃 설정
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# 헬스체크
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
log_success "설정 파일 생성 완료"
|
||||
|
||||
# 4. 환경 변수 파일 생성
|
||||
log_info "🔐 환경 변수 설정 중..."
|
||||
|
||||
cat > "$PROJECT_DIR/.env.synology" << EOF
|
||||
# Synology DS1525+ 배포 환경 변수
|
||||
DB_PASSWORD=$DB_PASSWORD
|
||||
SECRET_KEY=$SECRET_KEY
|
||||
ADMIN_EMAIL=$ADMIN_EMAIL
|
||||
ADMIN_PASSWORD=$ADMIN_PASSWORD
|
||||
DOMAIN_NAME=$DOMAIN_NAME
|
||||
|
||||
# 성능 최적화 설정
|
||||
POSTGRES_SHARED_BUFFERS=8GB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
|
||||
REDIS_MAXMEMORY=8gb
|
||||
|
||||
# 경로 설정
|
||||
SSD_PATH=/volume1/docker/document-server
|
||||
HDD_PATH=/volume2/document-storage
|
||||
EOF
|
||||
|
||||
log_success "환경 변수 설정 완료"
|
||||
|
||||
# 5. Docker Compose 배포
|
||||
log_info "🐳 Docker 컨테이너 배포 중..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# 기존 컨테이너 중지 및 제거 (있는 경우)
|
||||
if docker-compose -f "$COMPOSE_FILE" ps -q | grep -q .; then
|
||||
log_warning "기존 컨테이너를 중지합니다..."
|
||||
docker-compose -f "$COMPOSE_FILE" down
|
||||
fi
|
||||
|
||||
# 이미지 빌드 및 컨테이너 시작
|
||||
log_info "이미지 빌드 중..."
|
||||
docker-compose -f "$COMPOSE_FILE" build --no-cache
|
||||
|
||||
log_info "컨테이너 시작 중..."
|
||||
docker-compose -f "$COMPOSE_FILE" up -d
|
||||
|
||||
# 6. 서비스 상태 확인
|
||||
log_info "🔍 서비스 상태 확인 중..."
|
||||
|
||||
# 컨테이너 시작 대기
|
||||
sleep 30
|
||||
|
||||
# 헬스체크
|
||||
services=("database" "redis" "backend" "nginx")
|
||||
for service in "${services[@]}"; do
|
||||
if docker-compose -f "$COMPOSE_FILE" ps "$service" | grep -q "Up"; then
|
||||
log_success "$service 서비스 정상 실행 중"
|
||||
else
|
||||
log_error "$service 서비스 실행 실패"
|
||||
docker-compose -f "$COMPOSE_FILE" logs "$service"
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. 백업 스크립트 설정
|
||||
log_info "💾 백업 스크립트 설정 중..."
|
||||
|
||||
cat > /volume1/docker/document-server/backup.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
# 자동 백업 스크립트
|
||||
|
||||
BACKUP_DIR="/volume2/document-storage/backups"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# 데이터베이스 백업
|
||||
docker exec document-server-db pg_dump -U docuser document_db > "$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 "백업 완료: $DATE"
|
||||
EOF
|
||||
|
||||
chmod +x /volume1/docker/document-server/backup.sh
|
||||
|
||||
log_success "백업 스크립트 설정 완료"
|
||||
|
||||
# 8. 배포 완료 정보 출력
|
||||
log_success "🎉 Synology DS1525+ 배포 완료!"
|
||||
|
||||
echo ""
|
||||
echo "=== 배포 정보 ==="
|
||||
echo "🌐 웹 인터페이스: http://localhost:24100"
|
||||
echo "🔧 API 서버: http://localhost:24102"
|
||||
echo "🗄️ 데이터베이스: localhost:24101"
|
||||
echo "💾 Redis: localhost:24103"
|
||||
echo ""
|
||||
echo "=== 관리자 계정 ==="
|
||||
echo "📧 이메일: $ADMIN_EMAIL"
|
||||
echo "🔑 비밀번호: $ADMIN_PASSWORD"
|
||||
echo ""
|
||||
echo "=== 스토리지 구성 ==="
|
||||
echo "💿 SSD (성능): /volume1/docker/document-server/"
|
||||
echo "💾 HDD (용량): /volume2/document-storage/"
|
||||
echo ""
|
||||
echo "=== 자동 백업 ==="
|
||||
echo "📅 매일 새벽 2시 자동 백업 (Synology 작업 스케줄러에서 설정)"
|
||||
echo "📂 백업 위치: /volume2/document-storage/backups/"
|
||||
echo ""
|
||||
echo "=== 모니터링 명령어 ==="
|
||||
echo "docker-compose -f $COMPOSE_FILE ps"
|
||||
echo "docker-compose -f $COMPOSE_FILE logs -f"
|
||||
echo "docker stats"
|
||||
|
||||
log_info "배포 스크립트 실행 완료"
|
||||
249
scripts/monitor-synology.sh
Executable file
249
scripts/monitor-synology.sh
Executable file
@@ -0,0 +1,249 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# Document Server - Synology DS1525+ 모니터링 스크립트
|
||||
# 시스템 리소스 및 서비스 상태 모니터링
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 색상 정의
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 로그 함수
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 환경 설정
|
||||
COMPOSE_FILE="docker-compose.synology.yml"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
echo "=== 📊 Synology DS1525+ Document Server 모니터링 ==="
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
|
||||
# 1. 시스템 리소스 확인
|
||||
log_info "🖥️ 시스템 리소스 상태"
|
||||
|
||||
# CPU 사용률
|
||||
CPU_USAGE=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | awk -F'%' '{print $1}')
|
||||
echo -e "CPU 사용률: ${CYAN}${CPU_USAGE}%${NC}"
|
||||
|
||||
# 메모리 사용률 (32GB 기준)
|
||||
MEMORY_INFO=$(free -h | grep "Mem:")
|
||||
TOTAL_MEM=$(echo $MEMORY_INFO | awk '{print $2}')
|
||||
USED_MEM=$(echo $MEMORY_INFO | awk '{print $3}')
|
||||
AVAILABLE_MEM=$(echo $MEMORY_INFO | awk '{print $7}')
|
||||
MEM_PERCENT=$(free | grep "Mem:" | awk '{printf "%.1f", ($3/$2) * 100.0}')
|
||||
|
||||
echo -e "메모리: ${CYAN}${USED_MEM}${NC}/${CYAN}${TOTAL_MEM}${NC} (${CYAN}${MEM_PERCENT}%${NC}) | 사용 가능: ${GREEN}${AVAILABLE_MEM}${NC}"
|
||||
|
||||
# 디스크 사용률
|
||||
echo -e "\n${BLUE}💾 디스크 사용률:${NC}"
|
||||
df -h /volume1 /volume2 | grep -E "(volume1|volume2)" | while read line; do
|
||||
USAGE=$(echo $line | awk '{print $5}' | sed 's/%//')
|
||||
MOUNT=$(echo $line | awk '{print $6}')
|
||||
USED=$(echo $line | awk '{print $3}')
|
||||
TOTAL=$(echo $line | awk '{print $2}')
|
||||
|
||||
if [ "$USAGE" -gt 90 ]; then
|
||||
echo -e " ${RED}${MOUNT}${NC}: ${RED}${USED}${NC}/${TOTAL} (${RED}${USAGE}%${NC}) ⚠️"
|
||||
elif [ "$USAGE" -gt 80 ]; then
|
||||
echo -e " ${YELLOW}${MOUNT}${NC}: ${YELLOW}${USED}${NC}/${TOTAL} (${YELLOW}${USAGE}%${NC})"
|
||||
else
|
||||
echo -e " ${GREEN}${MOUNT}${NC}: ${CYAN}${USED}${NC}/${TOTAL} (${GREEN}${USAGE}%${NC})"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# 2. Docker 컨테이너 상태
|
||||
log_info "🐳 Docker 컨테이너 상태"
|
||||
|
||||
if [ -f "$COMPOSE_FILE" ]; then
|
||||
# 컨테이너 상태 확인
|
||||
CONTAINERS=$(docker-compose -f "$COMPOSE_FILE" ps --format "table {{.Name}}\t{{.State}}\t{{.Ports}}")
|
||||
echo "$CONTAINERS"
|
||||
|
||||
echo ""
|
||||
|
||||
# 각 서비스별 상태 확인
|
||||
SERVICES=("database" "redis" "backend" "nginx")
|
||||
for service in "${SERVICES[@]}"; do
|
||||
STATUS=$(docker-compose -f "$COMPOSE_FILE" ps -q "$service" 2>/dev/null)
|
||||
if [ -n "$STATUS" ]; then
|
||||
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' $(docker-compose -f "$COMPOSE_FILE" ps -q "$service") 2>/dev/null || echo "no-healthcheck")
|
||||
if [ "$HEALTH" = "healthy" ]; then
|
||||
log_success "$service: 정상 (healthy)"
|
||||
elif [ "$HEALTH" = "unhealthy" ]; then
|
||||
log_error "$service: 비정상 (unhealthy)"
|
||||
else
|
||||
log_warning "$service: 헬스체크 없음"
|
||||
fi
|
||||
else
|
||||
log_error "$service: 실행 중이지 않음"
|
||||
fi
|
||||
done
|
||||
else
|
||||
log_error "Docker Compose 파일을 찾을 수 없습니다: $COMPOSE_FILE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 3. 리소스 사용량 (컨테이너별)
|
||||
log_info "📈 컨테이너별 리소스 사용량"
|
||||
|
||||
if command -v docker &> /dev/null; then
|
||||
# Docker stats 정보 (1회성)
|
||||
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}" | head -10
|
||||
else
|
||||
log_error "Docker가 설치되지 않았습니다"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 4. 네트워크 연결 상태
|
||||
log_info "🌐 네트워크 연결 상태"
|
||||
|
||||
# 포트 확인
|
||||
PORTS=("24100:nginx" "24101:database" "24102:backend" "24103:redis")
|
||||
for port_info in "${PORTS[@]}"; do
|
||||
PORT=$(echo $port_info | cut -d: -f1)
|
||||
SERVICE=$(echo $port_info | cut -d: -f2)
|
||||
|
||||
if netstat -tuln | grep -q ":$PORT "; then
|
||||
log_success "$SERVICE (포트 $PORT): 리스닝 중"
|
||||
else
|
||||
log_error "$SERVICE (포트 $PORT): 리스닝하지 않음"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# 5. 로그 파일 크기 확인
|
||||
log_info "📝 로그 파일 상태"
|
||||
|
||||
LOG_DIRS=(
|
||||
"/volume1/docker/document-server/logs"
|
||||
"/volume1/docker/document-server/logs/nginx"
|
||||
)
|
||||
|
||||
for log_dir in "${LOG_DIRS[@]}"; do
|
||||
if [ -d "$log_dir" ]; then
|
||||
LOG_SIZE=$(du -sh "$log_dir" 2>/dev/null | cut -f1)
|
||||
echo -e " ${CYAN}${log_dir}${NC}: ${LOG_SIZE}"
|
||||
|
||||
# 큰 로그 파일 경고 (1GB 이상)
|
||||
LOG_SIZE_MB=$(du -sm "$log_dir" 2>/dev/null | cut -f1)
|
||||
if [ "$LOG_SIZE_MB" -gt 1024 ]; then
|
||||
log_warning "로그 디렉토리가 1GB를 초과했습니다: $log_dir"
|
||||
fi
|
||||
else
|
||||
log_warning "로그 디렉토리가 존재하지 않습니다: $log_dir"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# 6. 데이터베이스 연결 테스트
|
||||
log_info "🗄️ 데이터베이스 연결 테스트"
|
||||
|
||||
if docker-compose -f "$COMPOSE_FILE" ps -q database >/dev/null 2>&1; then
|
||||
DB_STATUS=$(docker-compose -f "$COMPOSE_FILE" exec -T database pg_isready -U docuser -d document_db 2>/dev/null)
|
||||
if echo "$DB_STATUS" | grep -q "accepting connections"; then
|
||||
log_success "PostgreSQL: 연결 가능"
|
||||
|
||||
# 데이터베이스 크기 확인
|
||||
DB_SIZE=$(docker-compose -f "$COMPOSE_FILE" exec -T database psql -U docuser -d document_db -t -c "SELECT pg_size_pretty(pg_database_size('document_db'));" 2>/dev/null | xargs)
|
||||
echo -e " 데이터베이스 크기: ${CYAN}${DB_SIZE}${NC}"
|
||||
else
|
||||
log_error "PostgreSQL: 연결 실패"
|
||||
fi
|
||||
else
|
||||
log_error "데이터베이스 컨테이너가 실행 중이지 않습니다"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 7. Redis 연결 테스트
|
||||
log_info "💾 Redis 연결 테스트"
|
||||
|
||||
if docker-compose -f "$COMPOSE_FILE" ps -q redis >/dev/null 2>&1; then
|
||||
REDIS_STATUS=$(docker-compose -f "$COMPOSE_FILE" exec -T redis redis-cli ping 2>/dev/null)
|
||||
if [ "$REDIS_STATUS" = "PONG" ]; then
|
||||
log_success "Redis: 연결 가능"
|
||||
|
||||
# Redis 메모리 사용량
|
||||
REDIS_MEMORY=$(docker-compose -f "$COMPOSE_FILE" exec -T redis redis-cli info memory | grep "used_memory_human" | cut -d: -f2 | tr -d '\r')
|
||||
echo -e " 메모리 사용량: ${CYAN}${REDIS_MEMORY}${NC}"
|
||||
else
|
||||
log_error "Redis: 연결 실패"
|
||||
fi
|
||||
else
|
||||
log_error "Redis 컨테이너가 실행 중이지 않습니다"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 8. 백업 상태 확인
|
||||
log_info "💾 백업 상태 확인"
|
||||
|
||||
BACKUP_DIR="/volume2/document-storage/backups"
|
||||
if [ -d "$BACKUP_DIR" ]; then
|
||||
BACKUP_COUNT=$(find "$BACKUP_DIR" -name "*.sql" -mtime -1 | wc -l)
|
||||
LATEST_BACKUP=$(find "$BACKUP_DIR" -name "*.sql" -type f -printf '%T@ %p\n' 2>/dev/null | sort -n | tail -1 | cut -d' ' -f2- | xargs basename 2>/dev/null || echo "없음")
|
||||
|
||||
echo -e " 백업 디렉토리: ${CYAN}${BACKUP_DIR}${NC}"
|
||||
echo -e " 최근 24시간 백업: ${CYAN}${BACKUP_COUNT}개${NC}"
|
||||
echo -e " 최신 백업: ${CYAN}${LATEST_BACKUP}${NC}"
|
||||
|
||||
if [ "$BACKUP_COUNT" -eq 0 ]; then
|
||||
log_warning "최근 24시간 내 백업이 없습니다"
|
||||
fi
|
||||
else
|
||||
log_error "백업 디렉토리가 존재하지 않습니다: $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 9. 권장 사항
|
||||
log_info "💡 권장 사항"
|
||||
|
||||
# 메모리 사용률이 높은 경우
|
||||
if [ "${MEM_PERCENT%.*}" -gt 80 ]; then
|
||||
log_warning "메모리 사용률이 높습니다 (${MEM_PERCENT}%). 모니터링이 필요합니다."
|
||||
fi
|
||||
|
||||
# 디스크 사용률 확인
|
||||
HIGH_DISK_USAGE=$(df /volume1 /volume2 | awk 'NR>1 {gsub(/%/, "", $5); if ($5 > 85) print $6 " (" $5 "%)"}')
|
||||
if [ -n "$HIGH_DISK_USAGE" ]; then
|
||||
log_warning "디스크 사용률이 높은 볼륨: $HIGH_DISK_USAGE"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 모니터링 완료 ==="
|
||||
echo "다음 명령어로 실시간 모니터링 가능:"
|
||||
echo " watch -n 5 '$0'"
|
||||
echo " docker-compose -f $COMPOSE_FILE logs -f"
|
||||
echo " docker stats"
|
||||
58
scripts/restore.sh
Executable file
58
scripts/restore.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Document Server 데이터베이스 복원 스크립트
|
||||
# 시놀로지 NAS 환경에서 사용
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "사용법: $0 <백업파일명>"
|
||||
echo "예시: $0 document_db_20241201_143000.sql.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BACKUP_FILE="$1"
|
||||
BACKUP_DIR="/volume1/docker/document-server/backups"
|
||||
CONTAINER_NAME="document-server-db"
|
||||
|
||||
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||
echo "❌ 백업 파일을 찾을 수 없습니다: $BACKUP_DIR/$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "⚠️ 주의: 현재 데이터베이스의 모든 데이터가 삭제됩니다!"
|
||||
read -p "계속하시겠습니까? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "복원이 취소되었습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔄 데이터베이스 복원 시작..."
|
||||
|
||||
# 압축 해제 (필요한 경우)
|
||||
if [[ $BACKUP_FILE == *.gz ]]; then
|
||||
echo "📦 백업 파일 압축 해제 중..."
|
||||
gunzip -c "$BACKUP_DIR/$BACKUP_FILE" > "/tmp/restore_temp.sql"
|
||||
SQL_FILE="/tmp/restore_temp.sql"
|
||||
else
|
||||
SQL_FILE="$BACKUP_DIR/$BACKUP_FILE"
|
||||
fi
|
||||
|
||||
# 기존 데이터베이스 삭제 및 재생성
|
||||
echo "🗑️ 기존 데이터베이스 삭제 중..."
|
||||
docker exec $CONTAINER_NAME psql -U docuser -d postgres -c "DROP DATABASE IF EXISTS document_db;"
|
||||
docker exec $CONTAINER_NAME psql -U docuser -d postgres -c "CREATE DATABASE document_db;"
|
||||
|
||||
# 백업 복원
|
||||
echo "📥 데이터베이스 복원 중..."
|
||||
docker exec -i $CONTAINER_NAME psql -U docuser -d document_db < "$SQL_FILE"
|
||||
|
||||
# 임시 파일 정리
|
||||
if [ -f "/tmp/restore_temp.sql" ]; then
|
||||
rm "/tmp/restore_temp.sql"
|
||||
fi
|
||||
|
||||
echo "✅ 데이터베이스 복원 완료"
|
||||
echo "🔄 백엔드 서비스 재시작 중..."
|
||||
docker restart document-server-backend
|
||||
|
||||
echo "🎉 복원 작업 완료"
|
||||
257
scripts/setup-env.sh
Executable file
257
scripts/setup-env.sh
Executable file
@@ -0,0 +1,257 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# Document Server - 환경 변수 설정 스크립트
|
||||
# 대화형으로 환경 변수를 설정하고 .env 파일을 생성합니다
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 색상 정의
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 로그 함수
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
# 보안 키 생성 함수
|
||||
generate_secure_key() {
|
||||
openssl rand -base64 32 | tr -d "=+/" | cut -c1-32
|
||||
}
|
||||
|
||||
generate_jwt_key() {
|
||||
openssl rand -base64 64 | tr -d "=+/" | cut -c1-64
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
openssl rand -base64 16 | tr -d "=+/" | cut -c1-12
|
||||
}
|
||||
|
||||
# 환경 설정
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
ENV_FILE="$PROJECT_DIR/.env.synology"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
echo "=== 🔧 Document Server 환경 변수 설정 ==="
|
||||
echo ""
|
||||
|
||||
# 기존 .env 파일 확인
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
log_warning "기존 환경 변수 파일이 있습니다: $ENV_FILE"
|
||||
echo ""
|
||||
read -p "기존 설정을 덮어쓰시겠습니까? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_info "기존 설정을 유지합니다"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 기존 파일 백업
|
||||
cp "$ENV_FILE" "$ENV_FILE.backup.$(date +%Y%m%d_%H%M%S)"
|
||||
log_info "기존 파일을 백업했습니다"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_info "환경 변수를 설정합니다. 엔터를 누르면 기본값/자동생성값을 사용합니다."
|
||||
echo ""
|
||||
|
||||
# 1. 데이터베이스 비밀번호
|
||||
echo -e "${CYAN}1. 데이터베이스 비밀번호${NC}"
|
||||
DEFAULT_DB_PASSWORD=$(generate_password)
|
||||
echo " 기본값: $DEFAULT_DB_PASSWORD (자동생성)"
|
||||
read -p " 입력: " DB_PASSWORD
|
||||
DB_PASSWORD=${DB_PASSWORD:-$DEFAULT_DB_PASSWORD}
|
||||
|
||||
# 2. JWT 시크릿 키
|
||||
echo ""
|
||||
echo -e "${CYAN}2. JWT 시크릿 키 (보안용)${NC}"
|
||||
DEFAULT_SECRET_KEY=$(generate_jwt_key)
|
||||
echo " 기본값: ${DEFAULT_SECRET_KEY:0:20}... (자동생성)"
|
||||
read -p " 입력: " SECRET_KEY
|
||||
SECRET_KEY=${SECRET_KEY:-$DEFAULT_SECRET_KEY}
|
||||
|
||||
# 3. 관리자 이메일
|
||||
echo ""
|
||||
echo -e "${CYAN}3. 관리자 이메일${NC}"
|
||||
DEFAULT_ADMIN_EMAIL="admin@document-server.local"
|
||||
echo " 기본값: $DEFAULT_ADMIN_EMAIL"
|
||||
read -p " 입력: " ADMIN_EMAIL
|
||||
ADMIN_EMAIL=${ADMIN_EMAIL:-$DEFAULT_ADMIN_EMAIL}
|
||||
|
||||
# 4. 관리자 비밀번호
|
||||
echo ""
|
||||
echo -e "${CYAN}4. 관리자 비밀번호${NC}"
|
||||
DEFAULT_ADMIN_PASSWORD=$(generate_password)
|
||||
echo " 기본값: $DEFAULT_ADMIN_PASSWORD (자동생성)"
|
||||
read -p " 입력: " ADMIN_PASSWORD
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD:-$DEFAULT_ADMIN_PASSWORD}
|
||||
|
||||
# 5. 도메인 이름
|
||||
echo ""
|
||||
echo -e "${CYAN}5. 도메인 이름 (외부 접속용)${NC}"
|
||||
DEFAULT_DOMAIN="localhost"
|
||||
echo " 기본값: $DEFAULT_DOMAIN"
|
||||
echo " 예시: mydomain.com, nas.mydomain.com"
|
||||
read -p " 입력: " DOMAIN_NAME
|
||||
DOMAIN_NAME=${DOMAIN_NAME:-$DEFAULT_DOMAIN}
|
||||
|
||||
# 6. 외부 포트 (선택사항)
|
||||
echo ""
|
||||
echo -e "${CYAN}6. 외부 포트 (기본: 24100)${NC}"
|
||||
DEFAULT_PORT="24100"
|
||||
echo " 기본값: $DEFAULT_PORT"
|
||||
read -p " 입력: " EXTERNAL_PORT
|
||||
EXTERNAL_PORT=${EXTERNAL_PORT:-$DEFAULT_PORT}
|
||||
|
||||
# .env 파일 생성
|
||||
echo ""
|
||||
log_info "환경 변수 파일 생성 중..."
|
||||
|
||||
cat > "$ENV_FILE" << EOF
|
||||
# =============================================================================
|
||||
# Document Server - Synology DS1525+ 환경 변수
|
||||
# 생성일: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
# =============================================================================
|
||||
|
||||
# 데이터베이스 설정
|
||||
DB_PASSWORD=$DB_PASSWORD
|
||||
POSTGRES_PASSWORD=$DB_PASSWORD
|
||||
|
||||
# 보안 설정
|
||||
SECRET_KEY=$SECRET_KEY
|
||||
JWT_SECRET_KEY=$SECRET_KEY
|
||||
|
||||
# 관리자 계정
|
||||
ADMIN_EMAIL=$ADMIN_EMAIL
|
||||
ADMIN_PASSWORD=$ADMIN_PASSWORD
|
||||
|
||||
# 네트워크 설정
|
||||
DOMAIN_NAME=$DOMAIN_NAME
|
||||
EXTERNAL_PORT=$EXTERNAL_PORT
|
||||
|
||||
# CORS 설정 (도메인에 따라 자동 설정)
|
||||
ALLOWED_ORIGINS=http://localhost:$EXTERNAL_PORT,http://$DOMAIN_NAME:$EXTERNAL_PORT
|
||||
|
||||
# 성능 최적화 설정 (DS1525+ 32GB RAM)
|
||||
POSTGRES_SHARED_BUFFERS=8GB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
|
||||
POSTGRES_WORK_MEM=512MB
|
||||
POSTGRES_MAINTENANCE_WORK_MEM=4GB
|
||||
|
||||
# Redis 설정
|
||||
REDIS_MAXMEMORY=8gb
|
||||
REDIS_MAXMEMORY_POLICY=allkeys-lru
|
||||
|
||||
# 로그 레벨
|
||||
LOG_LEVEL=INFO
|
||||
DEBUG=false
|
||||
|
||||
# 파일 업로드 설정
|
||||
MAX_FILE_SIZE=500000000
|
||||
UPLOAD_DIR=/app/uploads
|
||||
|
||||
# 백업 설정
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
AUTO_BACKUP_ENABLED=true
|
||||
|
||||
# 모니터링 설정
|
||||
HEALTH_CHECK_INTERVAL=30s
|
||||
HEALTH_CHECK_TIMEOUT=10s
|
||||
HEALTH_CHECK_RETRIES=3
|
||||
|
||||
# 스토리지 경로 (Synology 최적화)
|
||||
SSD_PATH=/volume3/docker/document-server
|
||||
HDD_PATH=/volume1/document-storage
|
||||
|
||||
# 타임존 설정
|
||||
TZ=Asia/Seoul
|
||||
EOF
|
||||
|
||||
# 파일 권한 설정 (보안)
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
log_success "환경 변수 파일이 생성되었습니다: $ENV_FILE"
|
||||
|
||||
# 설정 요약 출력
|
||||
echo ""
|
||||
echo "=== 📋 설정 요약 ==="
|
||||
echo -e "데이터베이스 비밀번호: ${CYAN}$DB_PASSWORD${NC}"
|
||||
echo -e "관리자 이메일: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||
echo -e "관리자 비밀번호: ${CYAN}$ADMIN_PASSWORD${NC}"
|
||||
echo -e "도메인: ${CYAN}$DOMAIN_NAME${NC}"
|
||||
echo -e "포트: ${CYAN}$EXTERNAL_PORT${NC}"
|
||||
echo ""
|
||||
|
||||
# 보안 정보 저장
|
||||
SECURITY_INFO_FILE="/volume1/document-storage/backups/security-info-$(date +%Y%m%d_%H%M%S).txt"
|
||||
mkdir -p "$(dirname "$SECURITY_INFO_FILE")" 2>/dev/null || true
|
||||
|
||||
cat > "$SECURITY_INFO_FILE" << EOF
|
||||
Document Server 보안 정보
|
||||
생성일: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
=== 관리자 계정 ===
|
||||
이메일: $ADMIN_EMAIL
|
||||
비밀번호: $ADMIN_PASSWORD
|
||||
|
||||
=== 데이터베이스 ===
|
||||
사용자: docuser
|
||||
비밀번호: $DB_PASSWORD
|
||||
|
||||
=== 접속 정보 ===
|
||||
웹 인터페이스: http://$DOMAIN_NAME:$EXTERNAL_PORT
|
||||
API 문서: http://$DOMAIN_NAME:$((EXTERNAL_PORT + 2))/docs
|
||||
|
||||
=== 중요 안내 ===
|
||||
- 이 파일은 안전한 곳에 보관하세요
|
||||
- 비밀번호는 정기적으로 변경하세요
|
||||
- 외부 접속 시 HTTPS 사용을 권장합니다
|
||||
EOF
|
||||
|
||||
chmod 600 "$SECURITY_INFO_FILE" 2>/dev/null || true
|
||||
|
||||
log_success "보안 정보가 저장되었습니다: $SECURITY_INFO_FILE"
|
||||
|
||||
# 다음 단계 안내
|
||||
echo ""
|
||||
echo "=== 🚀 다음 단계 ==="
|
||||
echo "1. 배포 실행:"
|
||||
echo " ${CYAN}./scripts/deploy-synology.sh${NC}"
|
||||
echo ""
|
||||
echo "2. 상태 확인:"
|
||||
echo " ${CYAN}./scripts/monitor-synology.sh${NC}"
|
||||
echo ""
|
||||
echo "3. 웹 접속:"
|
||||
echo " ${CYAN}http://$DOMAIN_NAME:$EXTERNAL_PORT${NC}"
|
||||
echo ""
|
||||
|
||||
# SSL 설정 권장사항
|
||||
if [ "$DOMAIN_NAME" != "localhost" ]; then
|
||||
echo "=== 🔒 보안 권장사항 ==="
|
||||
echo "외부 도메인을 사용하시는 경우 SSL 인증서 설정을 권장합니다:"
|
||||
echo ""
|
||||
echo "1. Let's Encrypt 인증서 발급:"
|
||||
echo " ${CYAN}certbot certonly --webroot -w /volume2/document-storage/documents -d $DOMAIN_NAME${NC}"
|
||||
echo ""
|
||||
echo "2. Nginx SSL 설정 추가"
|
||||
echo "3. 방화벽에서 HTTPS(443) 포트 개방"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
log_success "환경 변수 설정이 완료되었습니다!"
|
||||
303
scripts/update-synology.sh
Executable file
303
scripts/update-synology.sh
Executable file
@@ -0,0 +1,303 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# Document Server - Synology 업데이트 스크립트
|
||||
# Git을 통한 무중단 업데이트 및 롤백 지원
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 색상 정의
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 로그 함수
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 환경 설정
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
COMPOSE_FILE="docker-compose.synology.yml"
|
||||
BACKUP_DIR="/volume2/document-storage/backups"
|
||||
UPDATE_LOG="/volume1/docker/document-server/logs/update.log"
|
||||
|
||||
# 업데이트 모드 설정
|
||||
UPDATE_MODE="${1:-safe}" # safe, force, rollback
|
||||
|
||||
log_info "🔄 Document Server 업데이트 시작 (모드: $UPDATE_MODE)"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - 업데이트 시작: $UPDATE_MODE" >> "$UPDATE_LOG"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# 현재 상태 확인
|
||||
log_info "📊 현재 상태 확인 중..."
|
||||
|
||||
# Git 상태 확인
|
||||
if [ ! -d ".git" ]; then
|
||||
log_error "Git 저장소가 아닙니다. Git 클론으로 설치해주세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 현재 커밋 해시 저장 (롤백용)
|
||||
CURRENT_COMMIT=$(git rev-parse HEAD)
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
echo "이전 커밋: $CURRENT_COMMIT" >> "$UPDATE_LOG"
|
||||
|
||||
log_info "현재 브랜치: $CURRENT_BRANCH"
|
||||
log_info "현재 커밋: ${CURRENT_COMMIT:0:8}"
|
||||
|
||||
# 컨테이너 상태 확인
|
||||
if docker-compose -f "$COMPOSE_FILE" ps -q | grep -q .; then
|
||||
CONTAINERS_RUNNING=true
|
||||
log_info "컨테이너가 실행 중입니다"
|
||||
else
|
||||
CONTAINERS_RUNNING=false
|
||||
log_warning "컨테이너가 실행 중이지 않습니다"
|
||||
fi
|
||||
|
||||
# 롤백 모드
|
||||
if [ "$UPDATE_MODE" = "rollback" ]; then
|
||||
log_warning "🔙 롤백 모드 실행"
|
||||
|
||||
# 마지막 성공한 커밋으로 롤백
|
||||
LAST_SUCCESS=$(tail -n 20 "$UPDATE_LOG" | grep "업데이트 성공" | tail -n 1 | awk '{print $6}')
|
||||
|
||||
if [ -z "$LAST_SUCCESS" ]; then
|
||||
log_error "롤백할 커밋을 찾을 수 없습니다"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "롤백 대상 커밋: $LAST_SUCCESS"
|
||||
|
||||
# 롤백 실행
|
||||
git reset --hard "$LAST_SUCCESS"
|
||||
|
||||
# 컨테이너 재시작
|
||||
if [ "$CONTAINERS_RUNNING" = true ]; then
|
||||
log_info "컨테이너 재시작 중..."
|
||||
docker-compose -f "$COMPOSE_FILE" down
|
||||
docker-compose -f "$COMPOSE_FILE" up -d --build
|
||||
fi
|
||||
|
||||
log_success "롤백 완료: $LAST_SUCCESS"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - 롤백 완료: $LAST_SUCCESS" >> "$UPDATE_LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 업데이트 가능 여부 확인
|
||||
log_info "🔍 업데이트 확인 중..."
|
||||
|
||||
# 원격 저장소에서 최신 정보 가져오기
|
||||
git fetch origin
|
||||
|
||||
# 업데이트 가능한 커밋 수 확인
|
||||
COMMITS_BEHIND=$(git rev-list --count HEAD..origin/$CURRENT_BRANCH)
|
||||
|
||||
if [ "$COMMITS_BEHIND" -eq 0 ]; then
|
||||
log_success "이미 최신 버전입니다"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log_info "업데이트 가능한 커밋: $COMMITS_BEHIND개"
|
||||
|
||||
# 변경사항 미리보기
|
||||
log_info "📝 변경사항 미리보기:"
|
||||
git log --oneline HEAD..origin/$CURRENT_BRANCH | head -10
|
||||
|
||||
# Safe 모드에서 사용자 확인
|
||||
if [ "$UPDATE_MODE" = "safe" ]; then
|
||||
echo ""
|
||||
read -p "업데이트를 진행하시겠습니까? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_info "업데이트가 취소되었습니다"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 백업 생성
|
||||
log_info "💾 백업 생성 중..."
|
||||
|
||||
BACKUP_TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_NAME="pre_update_${BACKUP_TIMESTAMP}"
|
||||
|
||||
# 데이터베이스 백업
|
||||
if [ "$CONTAINERS_RUNNING" = true ]; then
|
||||
docker-compose -f "$COMPOSE_FILE" exec -T database pg_dump -U docuser document_db > "$BACKUP_DIR/db_${BACKUP_NAME}.sql"
|
||||
log_success "데이터베이스 백업 완료"
|
||||
fi
|
||||
|
||||
# 설정 파일 백업
|
||||
tar -czf "$BACKUP_DIR/config_${BACKUP_NAME}.tar.gz" \
|
||||
/volume1/docker/document-server/config/ \
|
||||
.env.synology 2>/dev/null || true
|
||||
|
||||
log_success "설정 파일 백업 완료"
|
||||
|
||||
# Git 업데이트 실행
|
||||
log_info "📥 코드 업데이트 중..."
|
||||
|
||||
# 로컬 변경사항 임시 저장 (있는 경우)
|
||||
if ! git diff --quiet; then
|
||||
log_warning "로컬 변경사항을 임시 저장합니다"
|
||||
git stash push -m "Auto-stash before update $BACKUP_TIMESTAMP"
|
||||
fi
|
||||
|
||||
# 업데이트 실행
|
||||
git pull origin "$CURRENT_BRANCH"
|
||||
|
||||
NEW_COMMIT=$(git rev-parse HEAD)
|
||||
log_success "코드 업데이트 완료: ${NEW_COMMIT:0:8}"
|
||||
|
||||
# Docker 이미지 업데이트 확인
|
||||
log_info "🐳 Docker 이미지 업데이트 확인 중..."
|
||||
|
||||
# Dockerfile이나 requirements.txt 변경 확인
|
||||
NEED_REBUILD=false
|
||||
|
||||
if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(Dockerfile|requirements.txt|pyproject.toml|package.json)" > /dev/null; then
|
||||
NEED_REBUILD=true
|
||||
log_info "의존성 변경 감지 - 이미지 재빌드 필요"
|
||||
fi
|
||||
|
||||
# 컨테이너 업데이트
|
||||
if [ "$CONTAINERS_RUNNING" = true ]; then
|
||||
log_info "🔄 서비스 업데이트 중..."
|
||||
|
||||
if [ "$NEED_REBUILD" = true ]; then
|
||||
log_info "이미지 재빌드 중..."
|
||||
|
||||
# 무중단 업데이트를 위한 단계별 재시작
|
||||
docker-compose -f "$COMPOSE_FILE" build --no-cache backend
|
||||
docker-compose -f "$COMPOSE_FILE" up -d --no-deps backend
|
||||
|
||||
# 헬스체크 대기
|
||||
log_info "백엔드 헬스체크 대기 중..."
|
||||
sleep 30
|
||||
|
||||
# Nginx 업데이트 (필요시)
|
||||
if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(nginx|frontend)" > /dev/null; then
|
||||
docker-compose -f "$COMPOSE_FILE" build --no-cache nginx
|
||||
docker-compose -f "$COMPOSE_FILE" up -d --no-deps nginx
|
||||
fi
|
||||
else
|
||||
log_info "설정 파일만 업데이트 - 재시작 중..."
|
||||
docker-compose -f "$COMPOSE_FILE" restart backend nginx
|
||||
fi
|
||||
|
||||
# 서비스 상태 확인
|
||||
sleep 10
|
||||
|
||||
# 헬스체크
|
||||
HEALTH_CHECK_FAILED=false
|
||||
|
||||
# 백엔드 헬스체크
|
||||
if ! curl -f http://localhost:24102/health > /dev/null 2>&1; then
|
||||
log_error "백엔드 헬스체크 실패"
|
||||
HEALTH_CHECK_FAILED=true
|
||||
fi
|
||||
|
||||
# 프론트엔드 헬스체크
|
||||
if ! curl -f http://localhost:24100/ > /dev/null 2>&1; then
|
||||
log_error "프론트엔드 헬스체크 실패"
|
||||
HEALTH_CHECK_FAILED=true
|
||||
fi
|
||||
|
||||
# 헬스체크 실패 시 롤백
|
||||
if [ "$HEALTH_CHECK_FAILED" = true ]; then
|
||||
log_error "헬스체크 실패 - 자동 롤백 실행"
|
||||
|
||||
# 이전 커밋으로 롤백
|
||||
git reset --hard "$CURRENT_COMMIT"
|
||||
|
||||
# 컨테이너 롤백
|
||||
docker-compose -f "$COMPOSE_FILE" down
|
||||
docker-compose -f "$COMPOSE_FILE" up -d --build
|
||||
|
||||
log_error "업데이트 실패 - 이전 버전으로 롤백됨"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - 업데이트 실패 (롤백): $NEW_COMMIT -> $CURRENT_COMMIT" >> "$UPDATE_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "서비스 업데이트 완료"
|
||||
else
|
||||
log_info "컨테이너가 실행 중이지 않아 서비스 업데이트를 건너뜁니다"
|
||||
fi
|
||||
|
||||
# 데이터베이스 마이그레이션 확인
|
||||
log_info "🗄️ 데이터베이스 마이그레이션 확인 중..."
|
||||
|
||||
if git diff --name-only "$CURRENT_COMMIT" "$NEW_COMMIT" | grep -E "(migrations|models)" > /dev/null; then
|
||||
log_warning "데이터베이스 스키마 변경 감지"
|
||||
|
||||
if [ "$CONTAINERS_RUNNING" = true ]; then
|
||||
log_info "마이그레이션 실행 중..."
|
||||
docker-compose -f "$COMPOSE_FILE" exec -T backend python -m alembic upgrade head || true
|
||||
log_success "마이그레이션 완료"
|
||||
else
|
||||
log_warning "컨테이너가 실행 중이지 않아 마이그레이션을 건너뜁니다"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 업데이트 완료
|
||||
log_success "🎉 업데이트 완료!"
|
||||
|
||||
echo ""
|
||||
echo "=== 업데이트 정보 ==="
|
||||
echo "이전 커밋: ${CURRENT_COMMIT:0:8}"
|
||||
echo "새 커밋: ${NEW_COMMIT:0:8}"
|
||||
echo "업데이트된 커밋 수: $COMMITS_BEHIND"
|
||||
echo "백업 위치: $BACKUP_DIR/*_${BACKUP_NAME}.*"
|
||||
echo ""
|
||||
|
||||
# 변경사항 요약
|
||||
echo "=== 주요 변경사항 ==="
|
||||
git log --oneline "$CURRENT_COMMIT".."$NEW_COMMIT" | head -5
|
||||
|
||||
echo ""
|
||||
echo "=== 서비스 상태 ==="
|
||||
if [ "$CONTAINERS_RUNNING" = true ]; then
|
||||
docker-compose -f "$COMPOSE_FILE" ps
|
||||
echo ""
|
||||
echo "🌐 웹 인터페이스: http://localhost:24100"
|
||||
echo "🔧 API 문서: http://localhost:24102/docs"
|
||||
fi
|
||||
|
||||
# 성공 로그 기록
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') - 업데이트 성공: $CURRENT_COMMIT -> $NEW_COMMIT" >> "$UPDATE_LOG"
|
||||
|
||||
# 정리 작업
|
||||
log_info "🧹 정리 작업 중..."
|
||||
|
||||
# 오래된 백업 파일 정리 (30일 이상)
|
||||
find "$BACKUP_DIR" -name "pre_update_*" -mtime +30 -delete 2>/dev/null || true
|
||||
|
||||
# Docker 이미지 정리
|
||||
docker system prune -f > /dev/null 2>&1 || true
|
||||
|
||||
log_success "업데이트 프로세스 완료"
|
||||
|
||||
# 모니터링 실행 제안
|
||||
echo ""
|
||||
echo "💡 업데이트 후 시스템 상태를 확인하려면:"
|
||||
echo " ./scripts/monitor-synology.sh"
|
||||
echo ""
|
||||
echo "🔙 문제가 있으면 롤백하려면:"
|
||||
echo " ./scripts/update-synology.sh rollback"
|
||||
@@ -1,73 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>테스트 문서</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Document Server 테스트 문서</h1>
|
||||
|
||||
<h2>1. 소개</h2>
|
||||
<p>이 문서는 Document Server의 하이라이트 및 메모 기능을 테스트하기 위한 샘플 문서입니다.
|
||||
텍스트를 선택하면 하이라이트를 추가할 수 있고, 하이라이트에 메모를 연결할 수 있습니다.</p>
|
||||
|
||||
<h2>2. 주요 기능</h2>
|
||||
<ul>
|
||||
<li><strong>스마트 하이라이트</strong>: 텍스트를 선택하면 하이라이트 버튼이 나타납니다</li>
|
||||
<li><strong>연결된 메모</strong>: 하이라이트에 직접 메모를 추가할 수 있습니다</li>
|
||||
<li><strong>메모 관리</strong>: 사이드 패널에서 모든 메모를 확인하고 관리할 수 있습니다</li>
|
||||
<li><strong>책갈피</strong>: 중요한 위치에 책갈피를 추가하여 빠르게 이동할 수 있습니다</li>
|
||||
<li><strong>통합 검색</strong>: 문서 내용과 메모를 함께 검색할 수 있습니다</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. 사용 방법</h2>
|
||||
<h3>3.1 하이라이트 추가</h3>
|
||||
<p>원하는 텍스트를 마우스로 드래그하여 선택하세요. 선택된 텍스트 아래에 "하이라이트" 버튼이 나타납니다.
|
||||
버튼을 클릭하면 해당 텍스트가 하이라이트됩니다.</p>
|
||||
|
||||
<h3>3.2 메모 추가</h3>
|
||||
<p>하이라이트를 생성할 때 메모를 함께 추가할 수 있습니다. 또는 기존 하이라이트를 클릭하여
|
||||
메모를 추가하거나 편집할 수 있습니다.</p>
|
||||
|
||||
<h3>3.3 책갈피 사용</h3>
|
||||
<p>상단 도구 모음의 "책갈피" 버튼을 클릭하여 책갈피 패널을 열고,
|
||||
"현재 위치에 책갈피 추가" 버튼을 클릭하여 책갈피를 생성하세요.</p>
|
||||
|
||||
<h2>4. 고급 기능</h2>
|
||||
<blockquote>
|
||||
<p>이 부분은 인용문입니다. 중요한 내용을 강조할 때 사용됩니다.
|
||||
이런 텍스트도 하이라이트하고 메모를 추가할 수 있습니다.</p>
|
||||
</blockquote>
|
||||
|
||||
<h3>4.1 검색 기능</h3>
|
||||
<p>상단의 검색창을 사용하여 문서 내용을 검색할 수 있습니다.
|
||||
검색 결과는 노란색으로 하이라이트됩니다.</p>
|
||||
|
||||
<h3>4.2 태그 시스템</h3>
|
||||
<p>메모에 태그를 추가하여 분류할 수 있습니다. 태그는 쉼표로 구분하여 입력하세요.
|
||||
예: "중요, 질문, 아이디어"</p>
|
||||
|
||||
<h2>5. 결론</h2>
|
||||
<p>Document Server는 HTML 문서를 효율적으로 관리하고 주석을 달 수 있는 강력한 도구입니다.
|
||||
하이라이트, 메모, 책갈피 기능을 활용하여 문서를 더욱 효과적으로 활용해보세요.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>6. 추가 테스트 내용</h2>
|
||||
<p>이 섹션은 추가적인 테스트를 위한 내용입니다. 다양한 길이의 텍스트를 선택하여
|
||||
하이라이트 기능이 올바르게 작동하는지 확인해보세요.</p>
|
||||
|
||||
<p><em>기울임체 텍스트</em>와 <strong>굵은 텍스트</strong>, 그리고
|
||||
<code>코드 텍스트</code>도 모두 하이라이트할 수 있습니다.</p>
|
||||
|
||||
<ol>
|
||||
<li>첫 번째 항목 - 이것도 하이라이트 가능</li>
|
||||
<li>두 번째 항목 - 메모를 추가해보세요</li>
|
||||
<li>세 번째 항목 - 책갈피를 만들어보세요</li>
|
||||
</ol>
|
||||
|
||||
<p>마지막으로, 이 문서의 다양한 부분에 하이라이트와 메모를 추가한 후
|
||||
메모 패널에서 어떻게 표시되는지 확인해보세요. 검색 기능도 테스트해보시기 바랍니다.</p>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user