Compare commits
27 Commits
ec7d13ced7
...
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 690 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 570 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 885 KiB |
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
|
||||
```
|
||||
23
README.md
23
README.md
@@ -261,7 +261,7 @@ notes (
|
||||
- [ ] 문서 버전 관리
|
||||
- [ ] 성능 최적화 및 캐싱
|
||||
|
||||
## 현재 상태 (2025-08-21)
|
||||
## 현재 상태 (2025-01-26)
|
||||
|
||||
### ✅ 완료된 기능
|
||||
- **완전한 백엔드 API**: FastAPI + SQLAlchemy + PostgreSQL
|
||||
@@ -272,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
|
||||
@@ -302,10 +309,22 @@ 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+ 최적화 배포
|
||||
|
||||
### 하드웨어 사양
|
||||
|
||||
@@ -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();
|
||||
@@ -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(
|
||||
@@ -94,16 +94,22 @@ async def get_current_user_with_token_param(
|
||||
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"
|
||||
@@ -112,23 +118,27 @@ async def get_current_user_with_token_param(
|
||||
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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -516,9 +516,14 @@ async def get_document_pdf(
|
||||
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")
|
||||
|
||||
# 문서 조회
|
||||
@@ -527,10 +532,16 @@ async def get_document_pdf(
|
||||
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 파일 확인
|
||||
@@ -562,11 +573,20 @@ async def get_document_pdf(
|
||||
print(f"📂 디렉토리도 없음: {dir_path}")
|
||||
raise HTTPException(status_code=404, detail="PDF file not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
# 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")
|
||||
|
||||
@@ -253,6 +253,20 @@ def create_note_link(
|
||||
"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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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)}"
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -9,7 +9,7 @@ import uvicorn
|
||||
|
||||
from .core.config import settings
|
||||
from .core.database import init_db
|
||||
from .api.routes import auth, users, documents, highlights, notes, bookmarks, search, books, book_categories, memo_trees, document_links, notebooks, note_highlights, note_notes, setup
|
||||
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
|
||||
|
||||
|
||||
@@ -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=["*"],
|
||||
)
|
||||
|
||||
@@ -55,11 +55,14 @@ app.include_router(bookmarks.router, prefix="/api/bookmarks", tags=["책갈피"]
|
||||
app.include_router(search.router, prefix="/api/search", tags=["검색"])
|
||||
app.include_router(memo_trees.router, prefix="/api", tags=["트리 메모장"])
|
||||
app.include_router(document_links.router, prefix="/api/documents", tags=["문서 링크"])
|
||||
# 링크 삭제를 위한 추가 라우터 (document-links 경로 지원)
|
||||
app.include_router(document_links.router, prefix="/api", tags=["문서 링크 (호환성)"])
|
||||
app.include_router(note_documents.router, prefix="/api/note-documents", tags=["노트 문서"])
|
||||
app.include_router(note_links.router, prefix="/api", tags=["노트 링크"])
|
||||
app.include_router(notebooks.router, prefix="/api/notebooks", tags=["노트북"])
|
||||
app.include_router(note_highlights.router, prefix="/api", tags=["노트 하이라이트"])
|
||||
app.include_router(note_notes.router, prefix="/api", tags=["노트 메모"])
|
||||
app.include_router(todos.router, prefix="/api", tags=["할일관리"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -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):
|
||||
|
||||
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]}...')>"
|
||||
@@ -45,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}')>"
|
||||
|
||||
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>
|
||||
@@ -1,20 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL 데이터베이스 (SSD 최적화)
|
||||
# PostgreSQL 데이터베이스 (SSD 최적화 - 32GB RAM 활용)
|
||||
database:
|
||||
image: postgres:15-alpine
|
||||
container_name: document-server-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: document_server
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres123}
|
||||
POSTGRES_DB: document_db
|
||||
POSTGRES_USER: docuser
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-docpass}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
# SSD: 데이터베이스 성능 최적화
|
||||
- /volume1/docker/document-server/database:/var/lib/postgresql/data
|
||||
- /volume1/docker/document-server/config/postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
# 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: >
|
||||
@@ -22,37 +23,50 @@ services:
|
||||
-c config_file=/etc/postgresql/postgresql.conf
|
||||
-c shared_buffers=8GB
|
||||
-c effective_cache_size=24GB
|
||||
-c work_mem=256MB
|
||||
-c maintenance_work_mem=2GB
|
||||
-c work_mem=512MB
|
||||
-c maintenance_work_mem=4GB
|
||||
-c checkpoint_completion_target=0.9
|
||||
-c wal_buffers=64MB
|
||||
-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 postgres"]
|
||||
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 캐시 (SSD 최적화 - 대용량 메모리 활용)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: document-server-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# SSD: 캐시 성능 최적화
|
||||
- /volume1/docker/document-server/redis:/data
|
||||
# SSD: Redis 데이터 (빠른 캐시)
|
||||
- /volume3/docker/document-server/redis:/data
|
||||
ports:
|
||||
- "24103:6379"
|
||||
command: >
|
||||
redis-server
|
||||
--maxmemory 4gb
|
||||
--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
|
||||
@@ -60,8 +74,14 @@ services:
|
||||
retries: 3
|
||||
networks:
|
||||
- document-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 10G
|
||||
reservations:
|
||||
memory: 1G
|
||||
|
||||
# FastAPI 백엔드
|
||||
# FastAPI 백엔드 (SSD에서 실행, HDD 스토리지 연결)
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
@@ -69,18 +89,26 @@ services:
|
||||
container_name: document-server-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD:-postgres123}@database:5432/document_server
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-your-super-secret-jwt-key-change-this-in-production}
|
||||
- ENVIRONMENT=production
|
||||
- LOG_LEVEL=INFO
|
||||
- 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: 로그 및 설정 (빠른 액세스)
|
||||
- /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
|
||||
# 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:
|
||||
@@ -95,8 +123,14 @@ services:
|
||||
retries: 3
|
||||
networks:
|
||||
- document-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
reservations:
|
||||
memory: 512M
|
||||
|
||||
# Nginx 웹서버
|
||||
# Nginx 웹서버 (SSD 캐시, HDD 스토리지)
|
||||
nginx:
|
||||
build:
|
||||
context: ./nginx
|
||||
@@ -104,24 +138,35 @@ services:
|
||||
container_name: document-server-nginx
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# SSD: 설정 및 캐시 (빠른 액세스)
|
||||
- /volume1/docker/document-server/nginx:/etc/nginx/conf.d
|
||||
- /volume1/docker/document-server/logs/nginx:/var/log/nginx
|
||||
# HDD: 정적 파일 서빙 (읽기 전용)
|
||||
- /volume2/document-storage/documents:/usr/share/nginx/html/documents:ro
|
||||
# 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:80/health"]
|
||||
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:
|
||||
@@ -130,26 +175,4 @@ networks:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
volumes:
|
||||
# 명시적 볼륨 정의 (시놀로지 경로 매핑)
|
||||
database_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /volume1/docker/document-server/database
|
||||
|
||||
redis_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /volume1/docker/document-server/redis
|
||||
|
||||
document_storage:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /volume2/document-storage
|
||||
|
||||
# 볼륨 정의는 제거 (직접 경로 매핑 사용)
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
<span>통합 검색</span>
|
||||
</a>
|
||||
|
||||
<!-- 할일관리 -->
|
||||
<a href="todos.html" class="nav-link-modern" id="todos-nav-link">
|
||||
<i class="fas fa-tasks text-indigo-600"></i>
|
||||
<span>할일관리</span>
|
||||
</a>
|
||||
|
||||
<!-- 소설 관리 -->
|
||||
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
|
||||
<button class="nav-link-modern" id="novel-nav-link">
|
||||
|
||||
@@ -58,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">
|
||||
|
||||
@@ -656,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">
|
||||
|
||||
@@ -42,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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
<div id="header-container"></div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<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">
|
||||
|
||||
@@ -201,7 +201,7 @@ class DocumentServerAPI {
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
return await this.get('/users/me');
|
||||
return await this.get('/auth/me');
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken) {
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -699,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 인스턴스
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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,22 +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();
|
||||
|
||||
// 겹치는 링크나 백링크가 있는지 확인
|
||||
if (window.documentViewerInstance && window.documentViewerInstance.linkManager) {
|
||||
const overlappingElements = window.documentViewerInstance.linkManager.findOverlappingElements(span);
|
||||
if (overlappingElements.length > 0) {
|
||||
window.documentViewerInstance.linkManager.showOverlapMenu(e, span, overlappingElements, 'highlight');
|
||||
return;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
this.showHighlightModal(highlights);
|
||||
});
|
||||
|
||||
// DOM 교체
|
||||
@@ -498,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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 오프셋 계산
|
||||
*/
|
||||
@@ -597,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) {
|
||||
@@ -729,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);
|
||||
@@ -754,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>
|
||||
`;
|
||||
@@ -766,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>
|
||||
`;
|
||||
@@ -806,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>
|
||||
`;
|
||||
|
||||
|
||||
@@ -295,16 +295,36 @@ class LinkManager {
|
||||
box-sizing: border-box !important;
|
||||
`;
|
||||
|
||||
// 클릭 이벤트 추가 (겹치는 요소 감지)
|
||||
span.addEventListener('click', (e) => {
|
||||
// 클릭 이벤트 추가 (통합 툴팁 사용)
|
||||
span.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 겹치는 하이라이트나 백링크가 있는지 확인
|
||||
const overlappingElements = this.findOverlappingElements(span);
|
||||
if (overlappingElements.length > 0) {
|
||||
this.showOverlapMenu(e, span, overlappingElements, 'link');
|
||||
console.log('🔗 링크 클릭됨:', {
|
||||
text: span.textContent,
|
||||
linkId: link.id,
|
||||
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('🔗 단일 링크 툴팁 표시');
|
||||
this.showLinkTooltip(link, span);
|
||||
}
|
||||
});
|
||||
@@ -503,16 +523,36 @@ class LinkManager {
|
||||
box-sizing: border-box !important;
|
||||
`;
|
||||
|
||||
// 클릭 이벤트 추가 (겹치는 요소 감지)
|
||||
span.addEventListener('click', (e) => {
|
||||
// 클릭 이벤트 추가 (통합 툴팁 사용)
|
||||
span.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 겹치는 하이라이트나 링크가 있는지 확인
|
||||
const overlappingElements = this.findOverlappingElements(span);
|
||||
if (overlappingElements.length > 0) {
|
||||
this.showOverlapMenu(e, span, overlappingElements, 'backlink');
|
||||
console.log('🔙 백링크 클릭됨:', {
|
||||
text: span.textContent,
|
||||
backlinkId: backlink.id,
|
||||
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('🔙 단일 백링크 툴팁 표시');
|
||||
this.showBacklinkTooltip(backlink, span);
|
||||
}
|
||||
});
|
||||
@@ -530,7 +570,160 @@ class LinkManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 링크 툴팁 표시
|
||||
* 겹치는 링크들 찾기
|
||||
*/
|
||||
getOverlappingLinks(clickedElement) {
|
||||
const clickedLinkId = clickedElement.getAttribute('data-link-id');
|
||||
const clickedText = clickedElement.textContent;
|
||||
|
||||
console.log('🔍 겹치는 링크 찾기:', {
|
||||
clickedLinkId: clickedLinkId,
|
||||
clickedText: clickedText,
|
||||
totalLinks: this.documentLinks.length
|
||||
});
|
||||
|
||||
// 동일한 텍스트 범위에 있는 모든 링크 찾기
|
||||
const overlappingLinks = this.documentLinks.filter(link => {
|
||||
// 클릭된 링크와 텍스트가 겹치는지 확인
|
||||
const linkElement = document.querySelector(`[data-link-id="${link.id}"]`);
|
||||
if (!linkElement) return false;
|
||||
|
||||
// 텍스트가 겹치는지 확인 (간단한 방법: 텍스트 내용 비교)
|
||||
const isOverlapping = linkElement.textContent === clickedText;
|
||||
|
||||
if (isOverlapping) {
|
||||
console.log('✅ 겹치는 링크 발견:', {
|
||||
id: link.id,
|
||||
text: linkElement.textContent,
|
||||
target: link.target_document_title
|
||||
});
|
||||
}
|
||||
|
||||
return isOverlapping;
|
||||
});
|
||||
|
||||
console.log(`🔍 총 ${overlappingLinks.length}개의 겹치는 링크 발견`);
|
||||
return overlappingLinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 링크 툴팁 표시
|
||||
*/
|
||||
showMultiLinkTooltip(links, element, selectedText) {
|
||||
console.log('🔗 다중 링크 툴팁 표시:', links.length, '개');
|
||||
|
||||
// 기존 툴팁 제거
|
||||
this.hideTooltip();
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.id = 'link-tooltip';
|
||||
tooltip.className = 'absolute bg-white border border-gray-300 rounded-lg shadow-lg p-4 z-50 max-w-lg';
|
||||
tooltip.style.minWidth = '400px';
|
||||
tooltip.style.maxHeight = '80vh';
|
||||
tooltip.style.overflowY = 'auto';
|
||||
|
||||
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-purple-500">
|
||||
"${selectedText}"
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (links.length > 1) {
|
||||
tooltipHTML += `
|
||||
<div class="mb-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
연결된 링크 (${links.length}개)
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
tooltipHTML += '<div class="space-y-3">';
|
||||
|
||||
links.forEach((link, index) => {
|
||||
const createdDate = link.created_at ? this.formatDate(link.created_at) : '알 수 없음';
|
||||
const isNote = link.target_content_type === 'note';
|
||||
const iconClass = isNote ? 'text-green-600' : 'text-purple-600';
|
||||
const bgClass = isNote ? 'hover:bg-green-50' : 'hover:bg-purple-50';
|
||||
|
||||
tooltipHTML += `
|
||||
<div class="border rounded-lg p-3 ${bgClass} transition-colors duration-200 relative group">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 cursor-pointer" onclick="window.documentViewerInstance.navigateToLink(${JSON.stringify(link).replace(/"/g, '"')})">
|
||||
<div class="flex items-center mb-2">
|
||||
<svg class="w-4 h-4 mr-2 ${iconClass}" fill="currentColor" viewBox="0 0 20 20">
|
||||
${isNote ?
|
||||
'<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>' :
|
||||
'<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"></path>'
|
||||
}
|
||||
</svg>
|
||||
<span class="font-medium ${iconClass}">${link.target_document_title}</span>
|
||||
</div>
|
||||
|
||||
<!-- 연결된 텍스트 정보 -->
|
||||
${link.target_text ? `
|
||||
<div class="mb-2 p-2 bg-gray-50 rounded border-l-3 ${isNote ? 'border-green-400' : 'border-purple-400'}">
|
||||
<div class="text-xs ${isNote ? 'text-green-700' : 'text-purple-700'} mb-1">연결된 텍스트</div>
|
||||
<div class="text-sm text-gray-800 font-medium">"${link.target_text}"</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${link.description ? `
|
||||
<div class="mb-2 p-2 bg-blue-50 rounded border-l-3 border-blue-400">
|
||||
<div class="text-xs text-blue-700 mb-1">링크 설명</div>
|
||||
<div class="text-sm text-blue-800">${link.description}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="text-xs text-gray-500 flex items-center justify-between">
|
||||
<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="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
${link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'}
|
||||
</span>
|
||||
<span>${createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 버튼 -->
|
||||
<button onclick="event.stopPropagation(); window.documentViewerInstance.deleteLinkWithConfirm('${link.id}', '${link.target_document_title.replace(/'/g, "\\'")}');"
|
||||
class="ml-3 p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors duration-200 opacity-0 group-hover:opacity-100"
|
||||
title="링크 삭제">
|
||||
<svg class="w-4 h-4" 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>
|
||||
`;
|
||||
});
|
||||
|
||||
tooltipHTML += '</div>';
|
||||
|
||||
tooltip.innerHTML = tooltipHTML;
|
||||
|
||||
// 위치 계산 및 표시
|
||||
const rect = element.getBoundingClientRect();
|
||||
tooltip.style.top = (rect.bottom + window.scrollY + 10) + 'px';
|
||||
tooltip.style.left = Math.max(10, rect.left + window.scrollX - 200) + 'px';
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
// 외부 클릭 시 툴팁 숨기기
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', this.handleTooltipOutsideClick.bind(this));
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* 링크 툴팁 표시 (단일 링크용)
|
||||
*/
|
||||
showLinkTooltip(link, element) {
|
||||
this.hideTooltip();
|
||||
@@ -588,7 +781,7 @@ class LinkManager {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<button onclick="window.documentViewerInstance.linkManager.navigateToLinkedDocument('${link.target_document_id}', ${JSON.stringify(link).replace(/"/g, '"')})"
|
||||
<button onclick="window.documentViewerInstance.linkManager.navigateToLinkedDocument('${link.target_note_id || link.target_document_id}', ${JSON.stringify(link).replace(/"/g, '"')})"
|
||||
class="flex-1 bg-purple-500 text-white px-4 py-2 rounded-lg hover:bg-purple-600 transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
@@ -645,9 +838,28 @@ class LinkManager {
|
||||
<div class="text-xs text-gray-500">${createdDate}</div>
|
||||
</div>
|
||||
|
||||
<div class="font-medium text-orange-900 bg-orange-50 px-4 py-3 rounded-lg border-l-4 border-orange-500">
|
||||
<div class="text-sm text-orange-700 mb-1">현재 문서의 텍스트</div>
|
||||
<div class="text-base">"${backlink.target_text || backlink.selected_text}"</div>
|
||||
<!-- 백링크 설명 -->
|
||||
<div class="mb-4 p-3 bg-blue-50 rounded-lg border-l-4 border-blue-400">
|
||||
<div class="text-sm text-blue-800">
|
||||
<strong>💡 백링크란?</strong><br>
|
||||
다른 문서에서 현재 문서의 이 텍스트를 참조하는 연결입니다.<br>
|
||||
클릭하면 참조하는 문서로 이동할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 현재 문서의 참조된 텍스트 (강화된 정보) -->
|
||||
<div class="mb-4">
|
||||
<div class="font-medium text-orange-900 bg-orange-50 px-4 py-3 rounded-lg border-l-4 border-orange-500">
|
||||
<div class="text-sm text-orange-700 mb-1">현재 문서의 참조된 텍스트</div>
|
||||
<div class="text-base font-semibold">"${backlink.target_text || backlink.selected_text}"</div>
|
||||
</div>
|
||||
|
||||
${backlink.target_text && backlink.target_text !== backlink.selected_text ? `
|
||||
<div class="mt-2 p-3 bg-gray-50 rounded-lg border-l-4 border-gray-400">
|
||||
<div class="text-xs text-gray-600 mb-1">원본 링크에서 선택한 텍스트</div>
|
||||
<div class="text-sm text-gray-800">"${backlink.selected_text}"</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -656,28 +868,47 @@ class LinkManager {
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
참조하는 문서
|
||||
이 텍스트를 참조하는 문서
|
||||
</div>
|
||||
<div class="bg-gradient-to-r from-orange-50 to-orange-100 p-4 rounded-lg border">
|
||||
<div class="font-semibold text-gray-900 mb-2">${backlink.source_document_title}</div>
|
||||
<div class="bg-white p-3 rounded border-l-3 border-orange-400">
|
||||
<div class="text-xs text-gray-600 mb-1">원본 텍스트</div>
|
||||
<div class="text-sm text-gray-800">"${backlink.selected_text}"</div>
|
||||
</div>
|
||||
${backlink.description ? `
|
||||
<div class="mt-3 p-3 bg-orange-50 rounded border-l-3 border-orange-300">
|
||||
<div class="text-xs text-orange-700 mb-1">설명</div>
|
||||
<div class="text-sm text-orange-800">${backlink.description}</div>
|
||||
<div class="cursor-pointer" onclick="window.documentViewerInstance.navigateToBacklink(${JSON.stringify(backlink).replace(/"/g, '"')})">
|
||||
<div class="font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-orange-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
${backlink.source_document_title}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 원본 링크 정보 (강화된 표시) -->
|
||||
<div class="space-y-3">
|
||||
<div class="bg-white p-3 rounded border-l-3 border-orange-400">
|
||||
<div class="text-xs text-gray-600 mb-1">원본 문서에서 링크로 설정한 텍스트</div>
|
||||
<div class="text-sm text-gray-800 font-medium">"${backlink.selected_text}"</div>
|
||||
</div>
|
||||
|
||||
${backlink.target_text ? `
|
||||
<div class="bg-white p-3 rounded border-l-3 border-blue-400">
|
||||
<div class="text-xs text-blue-600 mb-1">현재 문서에서 연결된 구체적인 텍스트</div>
|
||||
<div class="text-sm text-blue-800 font-medium">"${backlink.target_text}"</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${backlink.description ? `
|
||||
<div class="bg-white p-3 rounded border-l-3 border-green-400">
|
||||
<div class="text-xs text-green-600 mb-1">링크 설명</div>
|
||||
<div class="text-sm text-green-800">${backlink.description}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div class="flex justify-center mb-4">
|
||||
<button onclick="window.documentViewerInstance.linkManager.navigateToSourceDocument('${backlink.source_document_id}', ${JSON.stringify(backlink).replace(/"/g, '"')})"
|
||||
class="flex-1 bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
class="bg-gradient-to-r from-orange-500 to-orange-600 text-white px-6 py-2 rounded-lg hover:from-orange-600 hover:to-orange-700 transition-all duration-200 flex items-center justify-center font-medium shadow-md hover:shadow-lg">
|
||||
<svg class="w-4 h-4 mr-2" 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-2M7 7l10 10M17 7v4h-4"></path>
|
||||
</svg>
|
||||
원본 문서로 이동
|
||||
</button>
|
||||
@@ -802,10 +1033,22 @@ class LinkManager {
|
||||
linkInfo: linkInfo
|
||||
});
|
||||
|
||||
if (!targetDocumentId) {
|
||||
console.error('❌ targetDocumentId가 없습니다!');
|
||||
alert('대상 문서 ID가 없습니다.');
|
||||
return;
|
||||
// targetDocumentId가 null이거나 'null' 문자열인 경우 처리
|
||||
if (!targetDocumentId || targetDocumentId === 'null' || targetDocumentId === null) {
|
||||
console.error('❌ targetDocumentId가 유효하지 않습니다:', targetDocumentId);
|
||||
console.log('🔍 linkInfo에서 대체 ID 찾기:', linkInfo);
|
||||
|
||||
// linkInfo에서 대체 ID 찾기 (노트 링크의 경우 target_note_id 우선)
|
||||
if (linkInfo && linkInfo.target_note_id && linkInfo.target_note_id !== 'null') {
|
||||
targetDocumentId = linkInfo.target_note_id;
|
||||
console.log('✅ linkInfo에서 target_note_id 발견:', targetDocumentId);
|
||||
} else if (linkInfo && linkInfo.target_document_id && linkInfo.target_document_id !== 'null') {
|
||||
targetDocumentId = linkInfo.target_document_id;
|
||||
console.log('✅ linkInfo에서 target_document_id 발견:', targetDocumentId);
|
||||
} else {
|
||||
alert('대상 문서 ID가 유효하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// contentType에 따라 적절한 URL 생성
|
||||
@@ -852,7 +1095,18 @@ class LinkManager {
|
||||
// source_content_type에 따라 적절한 URL 생성
|
||||
let targetUrl;
|
||||
|
||||
if (backlinkInfo.source_content_type === 'note') {
|
||||
// source_content_type이 없으면 ID로 추론
|
||||
let sourceContentType = backlinkInfo.source_content_type;
|
||||
if (!sourceContentType) {
|
||||
if (backlinkInfo.source_note_id) {
|
||||
sourceContentType = 'note';
|
||||
} else if (backlinkInfo.source_document_id) {
|
||||
sourceContentType = 'document';
|
||||
}
|
||||
console.log('🔍 백링크에서 source_content_type 추론됨:', sourceContentType);
|
||||
}
|
||||
|
||||
if (sourceContentType === 'note') {
|
||||
// 노트 문서로 이동
|
||||
targetUrl = `/viewer.html?id=${sourceDocumentId}&contentType=note`;
|
||||
console.log('📝 노트 문서로 이동 (백링크):', sourceDocumentId);
|
||||
@@ -883,7 +1137,19 @@ class LinkManager {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.delete(`/document-links/${linkId}`);
|
||||
// 링크 타입 확인 (노트 링크인지 문서 링크인지)
|
||||
const link = this.documentLinks.find(l => l.id === linkId);
|
||||
|
||||
if (link && link.source_note_id) {
|
||||
// 노트 링크 삭제
|
||||
console.log('📝 노트 링크 삭제 API 호출');
|
||||
await this.api.delete(`/note-links/${linkId}`);
|
||||
} else {
|
||||
// 문서 링크 삭제
|
||||
console.log('📄 문서 링크 삭제 API 호출');
|
||||
await this.api.delete(`/document-links/${linkId}`);
|
||||
}
|
||||
|
||||
this.documentLinks = this.documentLinks.filter(l => l.id !== linkId);
|
||||
|
||||
this.hideTooltip();
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,27 @@ window.documentViewer = () => ({
|
||||
contentType: 'document', // 'document' 또는 'note'
|
||||
navigation: null,
|
||||
|
||||
// ==================== PDF 뷰어 상태 ====================
|
||||
pdfSrc: '',
|
||||
pdfLoading: false,
|
||||
pdfError: false,
|
||||
pdfLoaded: false,
|
||||
|
||||
// ==================== PDF 검색 상태 ====================
|
||||
showPdfSearchModal: false,
|
||||
pdfSearchQuery: '',
|
||||
pdfSearchResults: [],
|
||||
pdfSearchLoading: false,
|
||||
|
||||
// ==================== PDF.js 뷰어 상태 ====================
|
||||
pdfDocument: null,
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
pdfScale: 1.0,
|
||||
pdfCanvas: null,
|
||||
pdfContext: null,
|
||||
pdfTextContent: [],
|
||||
|
||||
// ==================== 데이터 상태 ====================
|
||||
highlights: [],
|
||||
notes: [],
|
||||
@@ -246,19 +267,24 @@ window.documentViewer = () => ({
|
||||
// ==================== URL 파라미터 처리 ====================
|
||||
parseUrlParameters() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.documentId = urlParams.get('id');
|
||||
const rawId = urlParams.get('id');
|
||||
|
||||
// null 문자열이나 빈 값 처리
|
||||
if (!rawId || rawId === 'null' || rawId === 'undefined' || rawId.trim() === '') {
|
||||
console.error('❌ 유효하지 않은 문서 ID:', rawId);
|
||||
throw new Error('유효한 문서 ID가 필요합니다. URL을 확인해주세요.');
|
||||
}
|
||||
|
||||
this.documentId = rawId;
|
||||
// contentType 파라미터를 올바르게 가져오기 (type과 contentType 둘 다 지원)
|
||||
this.contentType = urlParams.get('contentType') || urlParams.get('type') || 'document';
|
||||
|
||||
console.log('🔍 URL 파싱 결과:', {
|
||||
rawId: rawId,
|
||||
documentId: this.documentId,
|
||||
contentType: this.contentType,
|
||||
fullURL: window.location.href
|
||||
});
|
||||
|
||||
if (!this.documentId) {
|
||||
throw new Error('문서 ID가 필요합니다.');
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 문서 로드 ====================
|
||||
@@ -275,6 +301,11 @@ window.documentViewer = () => ({
|
||||
this.document = await this.documentLoader.loadDocument(this.documentId);
|
||||
// 네비게이션 별도 로드
|
||||
this.navigation = await this.documentLoader.loadNavigation(this.documentId);
|
||||
|
||||
// PDF 문서인 경우 PDF 뷰어 준비
|
||||
if (this.document && this.document.pdf_path) {
|
||||
await this.loadPdfViewer();
|
||||
}
|
||||
}
|
||||
|
||||
// 관련 데이터 병렬 로드
|
||||
@@ -1136,6 +1167,424 @@ window.documentViewer = () => ({
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 통합 툴팁 처리 ====================
|
||||
/**
|
||||
* 클릭된 요소에서 링크, 백링크, 하이라이트 모두 찾기 (완전 개선 버전)
|
||||
*/
|
||||
getOverlappingElements(clickedElement) {
|
||||
const selectedText = clickedElement.textContent.trim();
|
||||
console.log('🔍 통합 요소 찾기 시작:', selectedText);
|
||||
console.log('🔍 하이라이트 매니저 상태:', {
|
||||
highlightManager: !!this.highlightManager,
|
||||
highlightsCount: this.highlightManager?.highlights?.length || 0,
|
||||
highlights: this.highlightManager?.highlights || []
|
||||
});
|
||||
|
||||
// 결과 배열들
|
||||
const overlappingLinks = [];
|
||||
const overlappingBacklinks = [];
|
||||
const overlappingHighlights = [];
|
||||
|
||||
// 1. 모든 링크 요소 찾기 (같은 텍스트)
|
||||
const allLinkElements = document.querySelectorAll('.document-link');
|
||||
allLinkElements.forEach(linkEl => {
|
||||
if (linkEl.textContent.trim() === selectedText) {
|
||||
const linkId = linkEl.dataset.linkId;
|
||||
const link = this.linkManager.documentLinks.find(l => l.id === linkId);
|
||||
if (link && !overlappingLinks.find(l => l.id === link.id)) {
|
||||
overlappingLinks.push(link);
|
||||
const linkTitle = link.target_note_title || link.target_document_title || 'Unknown';
|
||||
console.log('✅ 겹치는 링크 발견:', linkTitle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 모든 백링크 요소 찾기 (같은 텍스트)
|
||||
const allBacklinkElements = document.querySelectorAll('.backlink-highlight');
|
||||
allBacklinkElements.forEach(backlinkEl => {
|
||||
if (backlinkEl.textContent.trim() === selectedText) {
|
||||
const backlinkId = backlinkEl.dataset.backlinkId;
|
||||
const backlink = this.linkManager.backlinks.find(b => b.id === backlinkId);
|
||||
if (backlink && !overlappingBacklinks.find(b => b.id === backlink.id)) {
|
||||
overlappingBacklinks.push(backlink);
|
||||
console.log('✅ 겹치는 백링크 발견:', backlink.source_document_title);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 모든 하이라이트 요소 찾기 (같은 텍스트)
|
||||
const allHighlightElements = document.querySelectorAll('.highlight-span');
|
||||
console.log('🔍 페이지의 모든 하이라이트 요소:', allHighlightElements.length, '개');
|
||||
allHighlightElements.forEach(highlightEl => {
|
||||
const highlightText = highlightEl.textContent.trim();
|
||||
|
||||
// 텍스트가 정확히 일치하거나 포함 관계인 경우
|
||||
if (highlightText === selectedText ||
|
||||
highlightText.includes(selectedText) ||
|
||||
selectedText.includes(highlightText)) {
|
||||
|
||||
const highlightId = highlightEl.dataset.highlightId;
|
||||
console.log('🔍 하이라이트 요소 확인:', {
|
||||
element: highlightEl,
|
||||
highlightId: highlightId,
|
||||
text: highlightText,
|
||||
selectedText: selectedText
|
||||
});
|
||||
|
||||
const highlight = this.highlightManager.highlights.find(h => h.id === highlightId);
|
||||
if (highlight && !overlappingHighlights.find(h => h.id === highlight.id)) {
|
||||
overlappingHighlights.push(highlight);
|
||||
console.log('✅ 겹치는 하이라이트 발견:', {
|
||||
id: highlight.id,
|
||||
text: highlightText,
|
||||
color: highlight.highlight_color
|
||||
});
|
||||
} else if (!highlight) {
|
||||
console.log('❌ 하이라이트 데이터를 찾을 수 없음:', highlightId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📊 최종 발견된 요소들:', {
|
||||
links: overlappingLinks.length,
|
||||
backlinks: overlappingBacklinks.length,
|
||||
highlights: overlappingHighlights.length,
|
||||
selectedText: selectedText
|
||||
});
|
||||
|
||||
return {
|
||||
links: overlappingLinks,
|
||||
backlinks: overlappingBacklinks,
|
||||
highlights: overlappingHighlights,
|
||||
selectedText: selectedText
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 통합 툴팁 표시 (링크 + 하이라이트 + 백링크)
|
||||
*/
|
||||
async showUnifiedTooltip(overlappingElements, element) {
|
||||
const { links = [], highlights = [], backlinks = [], selectedText } = overlappingElements;
|
||||
|
||||
console.log('🎯 통합 툴팁 표시:', {
|
||||
links: links.length,
|
||||
highlights: highlights.length,
|
||||
backlinks: backlinks.length
|
||||
});
|
||||
|
||||
// 하이라이트가 있으면 메모 데이터 로드
|
||||
if (highlights.length > 0) {
|
||||
console.log('📝 통합 툴팁용 메모 로드 시작...');
|
||||
const documentId = this.documentId;
|
||||
const contentType = this.contentType;
|
||||
await this.highlightManager.loadNotes(documentId, contentType);
|
||||
console.log('📝 통합 툴팁용 메모 로드 완료:', this.highlightManager.notes.length, '개');
|
||||
}
|
||||
|
||||
// 기존 툴팁들 숨기기
|
||||
this.linkManager.hideTooltip();
|
||||
this.highlightManager.hideTooltip();
|
||||
|
||||
// 툴팁 컨테이너 생성
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.id = 'unified-tooltip';
|
||||
tooltip.className = 'fixed z-50 bg-white rounded-xl shadow-2xl border border-gray-200';
|
||||
tooltip.style.cssText = `
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
const totalElements = links.length + highlights.length + backlinks.length;
|
||||
|
||||
let tooltipHTML = `
|
||||
<div class="p-6">
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<svg class="w-5 h-5 mr-2 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/>
|
||||
겹치는 요소들
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">${totalElements}개 요소</div>
|
||||
</div>
|
||||
|
||||
<div class="font-medium text-gray-900 bg-purple-50 px-4 py-3 rounded-lg border-l-4 border-purple-500">
|
||||
<div class="text-sm text-purple-700 mb-1">선택된 텍스트</div>
|
||||
<div class="text-base">"${selectedText}"</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 하이라이트 섹션
|
||||
if (highlights.length > 0) {
|
||||
tooltipHTML += `
|
||||
<div class="mb-6">
|
||||
<div class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-yellow-600" 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"/>
|
||||
하이라이트 (${highlights.length}개)
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
`;
|
||||
|
||||
highlights.forEach(highlight => {
|
||||
const colorName = this.highlightManager.getColorName(highlight.highlight_color);
|
||||
const createdDate = this.formatDate(highlight.created_at);
|
||||
const notes = this.highlightManager.notes.filter(note => note.highlight_id === highlight.id);
|
||||
|
||||
console.log(`📝 통합 툴팁 - 하이라이트 ${highlight.id}의 메모:`, notes.length, '개');
|
||||
if (notes.length > 0) {
|
||||
console.log('📝 메모 내용:', notes.map(n => n.content));
|
||||
}
|
||||
|
||||
tooltipHTML += `
|
||||
<div class="border rounded-lg p-3 bg-gradient-to-r from-yellow-50 to-orange-50 cursor-pointer hover:shadow-md transition-shadow duration-200"
|
||||
onclick="window.documentViewerInstance.highlightManager.showHighlightTooltip([${JSON.stringify(highlight).replace(/"/g, '"')}], this.parentElement)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-3 h-3 rounded-full" style="background-color: ${highlight.highlight_color}"></div>
|
||||
<span class="text-sm font-medium text-gray-800">${colorName}</span>
|
||||
<span class="text-xs text-gray-500">${createdDate}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">${notes.length}개 메모</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
tooltipHTML += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 링크 섹션
|
||||
if (links.length > 0) {
|
||||
tooltipHTML += `
|
||||
<div class="mb-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
링크 (${links.length}개)
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
`;
|
||||
|
||||
links.forEach(link => {
|
||||
const isNote = link.target_content_type === 'note';
|
||||
const bgClass = isNote ? 'from-green-50 to-emerald-50' : 'from-purple-50 to-indigo-50';
|
||||
const iconClass = isNote ? 'text-green-600' : 'text-purple-600';
|
||||
const createdDate = this.formatDate(link.created_at);
|
||||
|
||||
tooltipHTML += `
|
||||
<div class="border rounded-lg p-3 bg-gradient-to-r ${bgClass} transition-colors duration-200 relative group">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 cursor-pointer" onclick="window.documentViewerInstance.navigateToLink(${JSON.stringify(link).replace(/"/g, '"')})">
|
||||
<div class="flex items-center mb-2">
|
||||
<svg class="w-4 h-4 mr-2 ${iconClass}" fill="currentColor" viewBox="0 0 20 20">
|
||||
${isNote ?
|
||||
'<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><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 fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/>'
|
||||
}
|
||||
</svg>
|
||||
<span class="font-medium ${iconClass}">${link.target_note_title || link.target_document_title}</span>
|
||||
</div>
|
||||
|
||||
${link.target_text ? `
|
||||
<div class="mb-2 p-2 bg-gray-50 rounded border-l-3 ${isNote ? 'border-green-400' : 'border-purple-400'}">
|
||||
<div class="text-xs ${isNote ? 'text-green-700' : 'text-purple-700'} mb-1">연결된 텍스트</div>
|
||||
<div class="text-sm text-gray-800 font-medium">"${link.target_text}"</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${link.description ? `
|
||||
<div class="mb-2 p-2 bg-blue-50 rounded border-l-3 border-blue-400">
|
||||
<div class="text-xs text-blue-700 mb-1">링크 설명</div>
|
||||
<div class="text-sm text-blue-800">${link.description}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="text-xs text-gray-500 flex items-center justify-between">
|
||||
<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="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/>
|
||||
${link.link_type === 'text_fragment' ? '텍스트 조각 링크' : '문서 링크'}
|
||||
</span>
|
||||
<span>${createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 삭제 버튼 -->
|
||||
<button onclick="event.stopPropagation(); window.documentViewerInstance.deleteLinkWithConfirm('${link.id}', '${(link.target_note_title || link.target_document_title).replace(/'/g, "\\'")}');"
|
||||
class="ml-3 p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors duration-200 opacity-0 group-hover:opacity-100"
|
||||
title="링크 삭제">
|
||||
<svg class="w-4 h-4" 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"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
tooltipHTML += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 백링크 섹션
|
||||
if (backlinks.length > 0) {
|
||||
tooltipHTML += `
|
||||
<div class="mb-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-3 flex items-center">
|
||||
<svg class="w-4 h-4 mr-2 text-orange-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
백링크 (${backlinks.length}개)
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
`;
|
||||
|
||||
backlinks.forEach(backlink => {
|
||||
const createdDate = this.formatDate(backlink.created_at);
|
||||
|
||||
tooltipHTML += `
|
||||
<div class="border rounded-lg p-3 bg-gradient-to-r from-orange-50 to-red-50 transition-colors duration-200 relative group">
|
||||
<div class="cursor-pointer" onclick="window.documentViewerInstance.navigateToBacklink(${JSON.stringify(backlink).replace(/"/g, '"')})">
|
||||
<div class="flex items-center mb-2">
|
||||
<svg class="w-4 h-4 mr-2 text-orange-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/>
|
||||
<span class="font-medium text-orange-600">${backlink.source_document_title}</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 p-2 bg-white rounded border-l-3 border-orange-400">
|
||||
<div class="text-xs text-orange-600 mb-1">원본 문서에서 링크로 설정한 텍스트</div>
|
||||
<div class="text-sm text-gray-800 font-medium">"${backlink.selected_text}"</div>
|
||||
</div>
|
||||
|
||||
${backlink.target_text ? `
|
||||
<div class="mb-2 p-2 bg-white rounded border-l-3 border-blue-400">
|
||||
<div class="text-xs text-blue-600 mb-1">현재 문서에서 연결된 구체적인 텍스트</div>
|
||||
<div class="text-sm text-blue-800 font-medium">"${backlink.target_text}"</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${backlink.description ? `
|
||||
<div class="mb-2 p-2 bg-white rounded border-l-3 border-green-400">
|
||||
<div class="text-xs text-green-600 mb-1">링크 설명</div>
|
||||
<div class="text-sm text-green-800">${backlink.description}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="text-xs text-gray-500 flex items-center justify-between">
|
||||
<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="M7.707 3.293a1 1 0 010 1.414L5.414 7H11a7 7 0 017 7v2a1 1 0 11-2 0v-2a5 5 0 00-5-5H5.414l2.293 2.293a1 1 0 11-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
백링크
|
||||
</span>
|
||||
<span>${createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
tooltipHTML += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
tooltipHTML += `
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200">
|
||||
<button onclick="window.documentViewerInstance.hideUnifiedTooltip()"
|
||||
class="text-xs bg-gray-500 text-white px-3 py-1 rounded hover:bg-gray-600 transition-colors">
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
tooltip.innerHTML = tooltipHTML;
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
// 위치 조정
|
||||
this.positionTooltip(tooltip, element);
|
||||
},
|
||||
|
||||
/**
|
||||
* 통합 툴팁 숨기기
|
||||
*/
|
||||
hideUnifiedTooltip() {
|
||||
const tooltip = document.getElementById('unified-tooltip');
|
||||
if (tooltip) {
|
||||
tooltip.remove();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 툴팁 위치 조정 (화면 밖으로 나가지 않도록 개선)
|
||||
*/
|
||||
positionTooltip(tooltip, element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
console.log('🎯 툴팁 위치 계산:', {
|
||||
elementRect: rect,
|
||||
tooltipSize: { width: tooltipRect.width, height: tooltipRect.height },
|
||||
viewport: { width: viewportWidth, height: viewportHeight }
|
||||
});
|
||||
|
||||
// 기본 위치: 요소 아래 중앙
|
||||
let left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2);
|
||||
let top = rect.bottom + scrollY + 10;
|
||||
|
||||
// 좌우 경계 체크 및 조정
|
||||
const margin = 20;
|
||||
if (left < margin) {
|
||||
left = margin;
|
||||
console.log('🔧 좌측 경계 조정:', left);
|
||||
} else if (left + tooltipRect.width > viewportWidth - margin) {
|
||||
left = viewportWidth - tooltipRect.width - margin;
|
||||
console.log('🔧 우측 경계 조정:', left);
|
||||
}
|
||||
|
||||
// 상하 경계 체크 및 조정
|
||||
if (top + tooltipRect.height > viewportHeight - margin) {
|
||||
// 요소 위쪽에 표시
|
||||
top = rect.top + scrollY - tooltipRect.height - 10;
|
||||
console.log('🔧 상단으로 이동:', top);
|
||||
|
||||
// 위쪽에도 공간이 부족하면 뷰포트 내에 강제로 맞춤
|
||||
if (top < margin) {
|
||||
top = margin;
|
||||
console.log('🔧 상단 경계 조정:', top);
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 위치 설정
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.left = `${left - scrollX}px`;
|
||||
tooltip.style.top = `${top - scrollY}px`;
|
||||
|
||||
console.log('✅ 최종 툴팁 위치:', {
|
||||
left: left - scrollX,
|
||||
top: top - scrollY
|
||||
});
|
||||
},
|
||||
|
||||
// ==================== 유틸리티 메서드 ====================
|
||||
formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleString('ko-KR');
|
||||
@@ -1145,20 +1594,6 @@ window.documentViewer = () => ({
|
||||
return new Date(dateString).toLocaleDateString('ko-KR');
|
||||
},
|
||||
|
||||
getColorName(color) {
|
||||
const colorNames = {
|
||||
'#FFFF00': '노란색',
|
||||
'#00FF00': '초록색',
|
||||
'#FF0000': '빨간색',
|
||||
'#0000FF': '파란색',
|
||||
'#FF00FF': '보라색',
|
||||
'#00FFFF': '청록색',
|
||||
'#FFA500': '주황색',
|
||||
'#FFC0CB': '분홍색'
|
||||
};
|
||||
return colorNames[color] || '기타';
|
||||
},
|
||||
|
||||
getSelectedBookTitle() {
|
||||
const selectedBook = this.availableBooks.find(book => book.id === this.linkForm.target_book_id);
|
||||
return selectedBook ? selectedBook.title : '서적을 선택하세요';
|
||||
@@ -1247,17 +1682,464 @@ window.documentViewer = () => ({
|
||||
console.log('🔙 백링크 클릭:', backlink);
|
||||
console.log('📋 백링크 상세 정보:', {
|
||||
source_document_id: backlink.source_document_id,
|
||||
source_note_id: backlink.source_note_id,
|
||||
source_content_type: backlink.source_content_type,
|
||||
source_document_title: backlink.source_document_title
|
||||
});
|
||||
|
||||
if (!backlink.source_document_id) {
|
||||
console.error('❌ 소스 문서 ID가 없습니다!', backlink);
|
||||
// 소스 ID 찾기 (노트 백링크의 경우 source_note_id 우선)
|
||||
const sourceId = backlink.source_note_id || backlink.source_document_id;
|
||||
if (!sourceId) {
|
||||
console.error('❌ 소스 문서/노트 ID가 없습니다!', backlink);
|
||||
alert('백링크 소스를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
return this.linkManager.navigateToSourceDocument(backlink.source_document_id, backlink);
|
||||
console.log('✅ 백링크 소스 ID 발견:', sourceId);
|
||||
return this.linkManager.navigateToSourceDocument(sourceId, backlink);
|
||||
},
|
||||
|
||||
// 링크 삭제 (확인 후)
|
||||
async deleteLinkWithConfirm(linkId, targetTitle) {
|
||||
console.log('🗑️ 링크 삭제 요청:', { linkId, targetTitle });
|
||||
|
||||
const confirmed = confirm(`"${targetTitle}"로의 링크를 삭제하시겠습니까?`);
|
||||
if (!confirmed) {
|
||||
console.log('❌ 링크 삭제 취소됨');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🗑️ 링크 삭제 시작:', linkId);
|
||||
|
||||
// 출발지 타입에 따라 다른 API 사용
|
||||
if (this.contentType === 'note') {
|
||||
// 노트에서 출발하는 링크: NoteLink API 사용
|
||||
console.log('📝 노트 링크 삭제 API 호출');
|
||||
await this.api.delete(`/note-links/${linkId}`);
|
||||
} else {
|
||||
// 문서에서 출발하는 링크: DocumentLink API 사용
|
||||
console.log('📄 문서 링크 삭제 API 호출');
|
||||
await this.api.deleteDocumentLink(linkId);
|
||||
}
|
||||
console.log('✅ 링크 삭제 성공');
|
||||
|
||||
// 툴팁 숨기기
|
||||
this.linkManager.hideTooltip();
|
||||
|
||||
// 캐시 무효화
|
||||
console.log('🗑️ 링크 캐시 무효화 시작...');
|
||||
if (window.cachedApi && window.cachedApi.invalidateRelatedCache) {
|
||||
if (this.contentType === 'note') {
|
||||
window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/links`, ['links']);
|
||||
} else {
|
||||
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/links`, ['links']);
|
||||
}
|
||||
console.log('✅ 링크 캐시 무효화 완료');
|
||||
}
|
||||
|
||||
// 링크 목록 새로고침
|
||||
console.log('🔄 링크 목록 새로고침 시작...');
|
||||
await this.linkManager.loadDocumentLinks(this.documentId, this.contentType);
|
||||
this.documentLinks = this.linkManager.documentLinks || [];
|
||||
console.log('📊 새로고침된 링크 개수:', this.documentLinks.length);
|
||||
|
||||
// 링크 렌더링
|
||||
console.log('🎨 링크 렌더링 시작...');
|
||||
this.linkManager.renderDocumentLinks();
|
||||
console.log('✅ 링크 렌더링 완료');
|
||||
|
||||
// 백링크도 다시 로드 (삭제된 링크가 다른 문서의 백링크였을 수 있음)
|
||||
console.log('🔄 백링크 새로고침 시작...');
|
||||
if (window.cachedApi && window.cachedApi.invalidateRelatedCache) {
|
||||
if (this.contentType === 'note') {
|
||||
window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/backlinks`, ['links']);
|
||||
} else {
|
||||
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']);
|
||||
}
|
||||
console.log('✅ 백링크 캐시도 무효화 완료');
|
||||
}
|
||||
await this.linkManager.loadBacklinks(this.documentId, this.contentType);
|
||||
this.backlinks = this.linkManager.backlinks || [];
|
||||
this.linkManager.renderBacklinks();
|
||||
console.log('✅ 백링크 새로고침 완료');
|
||||
|
||||
// 성공 메시지
|
||||
this.showSuccessMessage('링크가 삭제되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 링크 삭제 실패:', error);
|
||||
alert('링크 삭제에 실패했습니다: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// 성공 메시지 표시
|
||||
showSuccessMessage(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg z-50 transition-opacity duration-300';
|
||||
toast.textContent = message;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
// 하이라이트 관련 추가 기능들
|
||||
async changeHighlightColor(highlightId) {
|
||||
console.log('🎨 하이라이트 색상 변경:', highlightId);
|
||||
|
||||
const colors = [
|
||||
{ name: '노란색', value: '#FFFF00' },
|
||||
{ name: '초록색', value: '#00FF00' },
|
||||
{ name: '파란색', value: '#00BFFF' },
|
||||
{ name: '분홍색', value: '#FFB6C1' },
|
||||
{ name: '주황색', value: '#FFA500' },
|
||||
{ name: '보라색', value: '#DDA0DD' }
|
||||
];
|
||||
|
||||
const colorOptions = colors.map(c => `${c.name} (${c.value})`).join('\n');
|
||||
const selectedColor = prompt(`새로운 색상을 선택하세요:\n\n${colorOptions}\n\n색상 코드를 입력하세요 (예: #FFFF00):`);
|
||||
|
||||
if (selectedColor && selectedColor.match(/^#[0-9A-Fa-f]{6}$/)) {
|
||||
try {
|
||||
await this.highlightManager.updateHighlightColor(highlightId, selectedColor);
|
||||
this.showSuccessMessage('하이라이트 색상이 변경되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('❌ 색상 변경 실패:', error);
|
||||
alert('색상 변경에 실패했습니다: ' + error.message);
|
||||
}
|
||||
} else if (selectedColor !== null) {
|
||||
alert('올바른 색상 코드를 입력해주세요 (예: #FFFF00)');
|
||||
}
|
||||
},
|
||||
|
||||
async duplicateHighlight(highlightId) {
|
||||
console.log('📋 하이라이트 복사:', highlightId);
|
||||
|
||||
try {
|
||||
await this.highlightManager.duplicateHighlight(highlightId);
|
||||
this.showSuccessMessage('하이라이트가 복사되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('❌ 하이라이트 복사 실패:', error);
|
||||
alert('하이라이트 복사에 실패했습니다: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteHighlightWithConfirm(highlightId) {
|
||||
console.log('🗑️ 하이라이트 삭제 확인:', highlightId);
|
||||
|
||||
const confirmed = confirm('이 하이라이트를 삭제하시겠습니까?\n\n⚠️ 주의: 연결된 모든 메모도 함께 삭제됩니다.');
|
||||
if (!confirmed) {
|
||||
console.log('❌ 하이라이트 삭제 취소됨');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.highlightManager.deleteHighlight(highlightId);
|
||||
this.highlightManager.hideTooltip();
|
||||
this.showSuccessMessage('하이라이트가 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('❌ 하이라이트 삭제 실패:', error);
|
||||
alert('하이라이트 삭제에 실패했습니다: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== PDF 뷰어 관련 ====================
|
||||
async loadPdfViewer() {
|
||||
console.log('📄 PDF 뷰어 로드 시작');
|
||||
this.pdfLoading = true;
|
||||
this.pdfError = false;
|
||||
this.pdfLoaded = false;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token || token === 'null' || token === null) {
|
||||
throw new Error('인증 토큰이 없습니다. 다시 로그인해주세요.');
|
||||
}
|
||||
|
||||
// PDF 뷰어 URL 설정 (토큰 포함)
|
||||
this.pdfSrc = `/api/documents/${this.documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
||||
console.log('✅ PDF 뷰어 준비 완료:', this.pdfSrc);
|
||||
|
||||
// PDF.js로 PDF 로드
|
||||
await this.loadPdfWithPdfJs();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ PDF 뷰어 로드 실패:', error);
|
||||
this.pdfError = true;
|
||||
} finally {
|
||||
this.pdfLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadPdfWithPdfJs() {
|
||||
try {
|
||||
// PDF.js 워커 설정
|
||||
if (typeof pdfjsLib !== 'undefined') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
|
||||
console.log('📄 PDF.js로 PDF 로드 시작:', this.pdfSrc);
|
||||
|
||||
// PDF 문서 로드
|
||||
const loadingTask = pdfjsLib.getDocument(this.pdfSrc);
|
||||
this.pdfDocument = await loadingTask.promise;
|
||||
|
||||
this.totalPages = this.pdfDocument.numPages;
|
||||
this.currentPage = 1;
|
||||
|
||||
console.log(`✅ PDF 로드 완료: ${this.totalPages} 페이지`);
|
||||
|
||||
// 캔버스 초기화
|
||||
this.initPdfCanvas();
|
||||
|
||||
// 첫 페이지 렌더링
|
||||
await this.renderPdfPage(1);
|
||||
|
||||
this.pdfLoaded = true;
|
||||
} else {
|
||||
throw new Error('PDF.js 라이브러리가 로드되지 않았습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ PDF.js 로드 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
initPdfCanvas() {
|
||||
this.pdfCanvas = document.getElementById('pdf-canvas');
|
||||
if (this.pdfCanvas) {
|
||||
this.pdfContext = this.pdfCanvas.getContext('2d');
|
||||
}
|
||||
},
|
||||
|
||||
async renderPdfPage(pageNum) {
|
||||
if (!this.pdfDocument || !this.pdfCanvas) return;
|
||||
|
||||
try {
|
||||
console.log(`📄 페이지 ${pageNum} 렌더링 시작`);
|
||||
|
||||
const page = await this.pdfDocument.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale: this.pdfScale });
|
||||
|
||||
// 캔버스 크기 설정
|
||||
this.pdfCanvas.height = viewport.height;
|
||||
this.pdfCanvas.width = viewport.width;
|
||||
|
||||
// 페이지 렌더링
|
||||
const renderContext = {
|
||||
canvasContext: this.pdfContext,
|
||||
viewport: viewport
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
|
||||
// 텍스트 내용 추출 (검색용)
|
||||
const textContent = await page.getTextContent();
|
||||
this.pdfTextContent[pageNum] = textContent.items.map(item => item.str).join(' ');
|
||||
|
||||
console.log(`✅ 페이지 ${pageNum} 렌더링 완료`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ 페이지 ${pageNum} 렌더링 실패:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
handlePdfError() {
|
||||
console.error('❌ PDF iframe 로드 오류');
|
||||
this.pdfError = true;
|
||||
this.pdfLoading = false;
|
||||
},
|
||||
|
||||
async retryPdfLoad() {
|
||||
console.log('🔄 PDF 재로드 시도');
|
||||
await this.loadPdfViewer();
|
||||
},
|
||||
|
||||
// ==================== PDF 검색 관련 ====================
|
||||
openPdfSearchModal() {
|
||||
this.showPdfSearchModal = true;
|
||||
this.pdfSearchQuery = '';
|
||||
this.pdfSearchResults = [];
|
||||
|
||||
// 모달이 열린 후 입력 필드에 포커스
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('input[x-ref="searchInput"]');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
|
||||
async searchInPdf() {
|
||||
if (!this.pdfSearchQuery.trim()) {
|
||||
alert('검색어를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔍 PDF 검색 시작:', this.pdfSearchQuery);
|
||||
this.pdfSearchLoading = true;
|
||||
this.pdfSearchResults = [];
|
||||
|
||||
try {
|
||||
// 백엔드 API를 통해 PDF 내용 검색
|
||||
const searchResults = await this.api.get(
|
||||
`/documents/${this.documentId}/search-in-content?q=${encodeURIComponent(this.pdfSearchQuery)}`
|
||||
);
|
||||
|
||||
console.log('✅ PDF 검색 결과:', searchResults);
|
||||
|
||||
if (searchResults.matches && searchResults.matches.length > 0) {
|
||||
this.pdfSearchResults = searchResults.matches.map(match => ({
|
||||
page: match.page || 1,
|
||||
context: match.context || match.text || this.pdfSearchQuery,
|
||||
position: match.position || 0
|
||||
}));
|
||||
|
||||
console.log(`📄 ${this.pdfSearchResults.length}개의 검색 결과 발견`);
|
||||
|
||||
if (this.pdfSearchResults.length === 0) {
|
||||
alert('검색 결과를 찾을 수 없습니다.');
|
||||
}
|
||||
} else {
|
||||
alert('검색 결과를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ PDF 검색 실패:', error);
|
||||
alert('PDF 검색 중 오류가 발생했습니다: ' + error.message);
|
||||
} finally {
|
||||
this.pdfSearchLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
jumpToPdfResult(result) {
|
||||
console.log('📍 PDF 결과로 이동:', result);
|
||||
|
||||
// PDF URL에 페이지 번호 추가하여 해당 페이지로 이동
|
||||
const token = localStorage.getItem('access_token');
|
||||
let newPdfSrc = `/api/documents/${this.documentId}/pdf?_token=${encodeURIComponent(token)}`;
|
||||
|
||||
// 페이지 번호가 있으면 URL 프래그먼트로 추가
|
||||
if (result.page && result.page > 1) {
|
||||
newPdfSrc += `#page=${result.page}`;
|
||||
}
|
||||
|
||||
// PDF src 업데이트하여 해당 페이지로 이동
|
||||
this.pdfSrc = newPdfSrc;
|
||||
|
||||
console.log(`📄 페이지 ${result.page}로 이동:`, newPdfSrc);
|
||||
|
||||
// 잠시 후 검색 기능 활성화
|
||||
setTimeout(() => {
|
||||
const iframe = document.querySelector('#pdf-viewer-iframe');
|
||||
if (iframe && iframe.contentWindow) {
|
||||
try {
|
||||
iframe.contentWindow.focus();
|
||||
|
||||
// 브라우저 내장 검색 기능 활용
|
||||
if (iframe.contentWindow.find) {
|
||||
iframe.contentWindow.find(this.pdfSearchQuery);
|
||||
} else {
|
||||
// 대안: 사용자에게 수동 검색 안내
|
||||
this.showSuccessMessage(`페이지 ${result.page}로 이동했습니다. Ctrl+F를 눌러 "${this.pdfSearchQuery}"를 검색하세요.`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('PDF iframe 접근 제한:', e);
|
||||
this.showSuccessMessage(`페이지 ${result.page}로 이동했습니다. Ctrl+F를 눌러 "${this.pdfSearchQuery}"를 검색하세요.`);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 모달 닫기
|
||||
this.showPdfSearchModal = false;
|
||||
},
|
||||
|
||||
async editNote(noteId, currentContent) {
|
||||
console.log('✏️ 메모 편집:', noteId);
|
||||
console.log('🔍 HighlightManager 상태:', this.highlightManager);
|
||||
console.log('🔍 updateNote 함수 존재:', typeof this.highlightManager?.updateNote);
|
||||
|
||||
if (!this.highlightManager) {
|
||||
console.error('❌ HighlightManager가 초기화되지 않음');
|
||||
alert('하이라이트 매니저가 초기화되지 않았습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.highlightManager.updateNote !== 'function') {
|
||||
console.error('❌ updateNote 함수가 존재하지 않음');
|
||||
alert('메모 업데이트 함수가 존재하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = prompt('메모 내용을 수정하세요:', currentContent);
|
||||
if (newContent !== null && newContent.trim() !== currentContent) {
|
||||
try {
|
||||
await this.highlightManager.updateNote(noteId, newContent.trim());
|
||||
this.showSuccessMessage('메모가 수정되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('❌ 메모 수정 실패:', error);
|
||||
alert('메모 수정에 실패했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 백링크 삭제 (확인 후)
|
||||
async deleteBacklinkWithConfirm(backlinkId, sourceTitle) {
|
||||
console.log('🗑️ 백링크 삭제 요청:', { backlinkId, sourceTitle });
|
||||
|
||||
const confirmed = confirm(`"${sourceTitle}"에서 오는 백링크를 삭제하시겠습니까?\n\n⚠️ 주의: 이는 원본 문서의 링크를 삭제합니다.`);
|
||||
if (!confirmed) {
|
||||
console.log('❌ 백링크 삭제 취소됨');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🗑️ 백링크 삭제 시작:', backlinkId);
|
||||
|
||||
// 백링크 삭제는 실제로는 원본 링크를 삭제하는 것
|
||||
await this.api.deleteDocumentLink(backlinkId);
|
||||
console.log('✅ 백링크 삭제 성공');
|
||||
|
||||
// 툴팁 숨기기
|
||||
this.linkManager.hideTooltip();
|
||||
|
||||
// 캐시 무효화 (현재 문서의 백링크 캐시)
|
||||
console.log('🗑️ 백링크 캐시 무효화 시작...');
|
||||
if (window.cachedApi && window.cachedApi.invalidateRelatedCache) {
|
||||
if (this.contentType === 'note') {
|
||||
window.cachedApi.invalidateRelatedCache(`/note-documents/${this.documentId}/backlinks`, ['links']);
|
||||
} else {
|
||||
window.cachedApi.invalidateRelatedCache(`/documents/${this.documentId}/backlinks`, ['links']);
|
||||
}
|
||||
console.log('✅ 백링크 캐시 무효화 완료');
|
||||
}
|
||||
|
||||
// 백링크 목록 새로고침
|
||||
console.log('🔄 백링크 목록 새로고침 시작...');
|
||||
await this.linkManager.loadBacklinks(this.documentId, this.contentType);
|
||||
this.backlinks = this.linkManager.backlinks || [];
|
||||
console.log('📊 새로고침된 백링크 개수:', this.backlinks.length);
|
||||
|
||||
// 백링크 렌더링
|
||||
console.log('🎨 백링크 렌더링 시작...');
|
||||
this.linkManager.renderBacklinks();
|
||||
console.log('✅ 백링크 렌더링 완료');
|
||||
|
||||
// 성공 메시지
|
||||
this.showSuccessMessage('백링크가 삭제되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 백링크 삭제 실패:', error);
|
||||
alert('백링크 삭제에 실패했습니다: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// 북마크 관련
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
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">
|
||||
|
||||
@@ -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"
|
||||
@@ -773,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
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"
|
||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user