🚀 시놀로지 배포 준비 완료
✨ 주요 변경사항: - 단일 docker-compose.yml로 통합 (로컬/시놀로지 환경 지원) - 시놀로지 볼륨 매핑 설정 (volume1: 이미지, volume3: 데이터) - 통합 배포 가이드 및 자동 배포 스크립트 추가 - 완전한 Memos 스타일 워크플로우 구현 🎯 새로운 기능: - 📝 메모 작성 (upload.html) - 이미지 업로드 지원 - 📥 수신함 (inbox.html) - 메모 편집 및 Todo/보드 변환 - ✅ Todo 목록 (todo-list.html) - 오늘 할 일 관리 - 📋 보드 (board.html) - 프로젝트 관리, 접기/펼치기, 이미지 지원 - 📚 아카이브 (archive.html) - 완료된 보드 보관 - 🔐 초기 설정 화면 - 관리자 계정 생성 🔧 기술적 개선: - 이미지 업로드/편집 완전 지원 - 반응형 디자인 및 모바일 최적화 - 보드 완료 후 자동 숨김 처리 - 메모 편집 시 제목 필드 제거 - 테스트 로그인 버튼 제거 (프로덕션 준비) - 과거 코드 정리 (TodoService, CalendarSyncService 등) 📦 배포 관련: - env.synology.example - 시놀로지 환경 설정 템플릿 - SYNOLOGY_DEPLOYMENT_GUIDE.md - 상세한 배포 가이드 - deploy-synology.sh - 원클릭 자동 배포 스크립트 - Nginx 정적 파일 서빙 및 이미지 프록시 설정 🗑️ 정리된 파일: - 사용하지 않는 HTML 페이지들 (dashboard, calendar, checklist 등) - 복잡한 통합 서비스들 (integrations 폴더) - 중복된 시놀로지 설정 파일들
This commit is contained in:
192
README.md
192
README.md
@@ -1,31 +1,193 @@
|
||||
# 📱 Todo Project
|
||||
|
||||
간단하고 효율적인 할일 관리 시스템
|
||||
**메모 → 수신함 → Todo 관리**의 간단하고 직관적인 워크플로우
|
||||
|
||||
## 🎯 핵심 워크플로우
|
||||
|
||||
```
|
||||
📝 메모 작성 → 📥 수신함 확인 → ✅ Todo 변환 → 📋 Todo 관리
|
||||
```
|
||||
|
||||
### 3개 페이지로 완성되는 심플한 구조:
|
||||
1. **📝 새 메모** (`/upload.html`) - 빠른 메모 작성
|
||||
2. **📥 수신함** (`/inbox.html`) - 메모 확인 & Todo 변환
|
||||
3. **📋 Todo 목록** (`/todo-list.html`) - 오늘 할 일 관리
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- 📊 **반응형 대시보드**: 데스크톱/모바일 최적화
|
||||
- 📥 **스마트 분류**: AI 기반 자동 분류 제안
|
||||
- 📷 **이미지 업로드**: 사진과 함께 메모 관리
|
||||
- 🏷️ **3가지 분류**: Todo, 캘린더, 체크리스트
|
||||
- 📱 **PWA 지원**: 홈화면 추가 가능
|
||||
- 🔄 **시놀로지 연동**: 메일플러스 자동 연동
|
||||
- 🖊️ **빠른 메모 작성**: 텍스트 + 이미지 (최대 5장)
|
||||
- 📅 **시작일 기반 Todo**: 해야 할 시점이 된 Todo만 표시
|
||||
- 🏷️ **스마트 상태 관리**: 오늘 시작 / 진행 중 자동 구분
|
||||
- 📱 **모바일 최적화**: 카메라/갤러리 업로드 지원
|
||||
- 🔄 **지연 관리**: +3일, +5일, 날짜 선택 연장
|
||||
- 🎨 **빈티지 UI**: 양피지 테마의 아름다운 인터페이스
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
```bash
|
||||
# 프로젝트 클론
|
||||
git clone https://git.hyungi.net/hyungi/Todo-Project.git
|
||||
cd Todo-Project
|
||||
|
||||
# Docker로 실행
|
||||
docker-compose up -d
|
||||
|
||||
# 접속
|
||||
open http://localhost:4000
|
||||
```
|
||||
|
||||
**기본 계정**: `hyungi` / `admin`
|
||||
|
||||
## 🏗️ 기술 스택
|
||||
|
||||
### Frontend
|
||||
- **HTML5/CSS3/JavaScript**: 바닐라 JS로 가벼운 구현
|
||||
- **Tailwind CSS**: 빠른 스타일링
|
||||
- **PWA**: 홈화면 추가 지원
|
||||
- **Nginx**: 정적 파일 서빙 & API 프록시
|
||||
|
||||
### Backend
|
||||
- **FastAPI**: 고성능 Python API
|
||||
- **SQLAlchemy**: 비동기 ORM
|
||||
- **PostgreSQL**: 안정적인 데이터베이스
|
||||
- **Pydantic**: 데이터 검증
|
||||
|
||||
### Infrastructure
|
||||
- **Docker Compose**: 컨테이너 오케스트레이션
|
||||
- **Nginx**: 리버스 프록시
|
||||
- **Volume**: 데이터 영속성
|
||||
|
||||
## 📊 데이터베이스 구조
|
||||
|
||||
### 📋 todos 테이블
|
||||
```sql
|
||||
Column | Type | 설명
|
||||
-------------|--------------------------|------------------
|
||||
id | uuid | 고유 ID
|
||||
user_id | uuid | 사용자 ID
|
||||
title | varchar(200) | 제목 (메모는 선택사항)
|
||||
description | text | 내용 (필수)
|
||||
category | enum | MEMO | TODO
|
||||
status | enum | pending | completed
|
||||
start_date | timestamp | Todo 시작일
|
||||
image_urls | text | 이미지 URLs (JSON)
|
||||
created_at | timestamp | 생성일
|
||||
updated_at | timestamp | 수정일
|
||||
completed_at | timestamp | 완료일
|
||||
```
|
||||
|
||||
### 🚀 성능 최적화 인덱스
|
||||
- `idx_todos_workflow`: 복합 워크플로우 조회 최적화
|
||||
- `idx_todos_start_date`: 시작일 기준 조회
|
||||
- `idx_todos_category_status`: 카테고리별 상태 조회
|
||||
|
||||
## 🔄 API 엔드포인트
|
||||
|
||||
### 인증
|
||||
- `POST /api/auth/login` - 로그인
|
||||
- `GET /api/auth/me` - 사용자 정보
|
||||
|
||||
### Todo/메모 관리
|
||||
- `POST /api/todos` - 메모/Todo 생성
|
||||
- `GET /api/todos?category=memo` - 메모 목록 (수신함)
|
||||
- `GET /api/todos?category=todo` - Todo 목록
|
||||
- `PUT /api/todos/{id}` - Todo 수정 (카테고리 변환, 상태 변경)
|
||||
- `POST /api/todos/upload-image` - 이미지 업로드
|
||||
|
||||
## 📱 모바일 지원
|
||||
|
||||
### PWA 기능
|
||||
- 홈화면 추가 가능
|
||||
- 오프라인 지원 (예정)
|
||||
- 푸시 알림 (예정)
|
||||
|
||||
### 모바일 최적화
|
||||
- 터치 친화적 UI
|
||||
- 카메라/갤러리 접근
|
||||
- 키보드 대응 스크롤
|
||||
- 이미지 자동 압축
|
||||
|
||||
## 🎨 UI/UX 특징
|
||||
|
||||
### 빈티지 양피지 테마
|
||||
- 따뜻한 세피아 색상
|
||||
- 손글씨 느낌의 폰트
|
||||
- 그림자와 테두리 효과
|
||||
- 직관적인 아이콘
|
||||
|
||||
### 반응형 디자인
|
||||
- 데스크톱: 넓은 레이아웃
|
||||
- 모바일: 세로 최적화
|
||||
- 터치 제스처 지원
|
||||
|
||||
## 🔧 개발 환경 설정
|
||||
|
||||
### 로컬 개발
|
||||
```bash
|
||||
# 백엔드 개발
|
||||
cd backend
|
||||
pip install -e .
|
||||
uvicorn src.main:app --reload --port 9000
|
||||
|
||||
# 프론트엔드 개발
|
||||
cd frontend
|
||||
python -m http.server 8000
|
||||
```
|
||||
|
||||
### 환경 변수
|
||||
```env
|
||||
# 데이터베이스
|
||||
DATABASE_URL=postgresql+asyncpg://todo_user:todo_password@localhost:5432/todo_db
|
||||
|
||||
# JWT 설정
|
||||
SECRET_KEY=your-secret-key
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# 파일 업로드
|
||||
UPLOAD_DIR=/data/uploads
|
||||
MAX_FILE_SIZE=5242880 # 5MB
|
||||
```
|
||||
|
||||
## 📈 성능 최적화
|
||||
|
||||
### 데이터베이스
|
||||
- 복합 인덱스로 쿼리 최적화
|
||||
- 카테고리별 분리로 효율적 조회
|
||||
- 이미지 URL JSON 저장으로 정규화 최소화
|
||||
|
||||
### 프론트엔드
|
||||
- 바닐라 JS로 번들 크기 최소화
|
||||
- 이미지 클라이언트 압축
|
||||
- 불필요한 로깅 제거
|
||||
- Nginx 정적 파일 캐싱
|
||||
|
||||
## 🚀 배포
|
||||
|
||||
### Docker Compose (권장)
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
접속: http://localhost:4000
|
||||
### 시놀로지 NAS
|
||||
자세한 설치 가이드: [SYNOLOGY_INSTALL.md](SYNOLOGY_INSTALL.md)
|
||||
|
||||
## 📋 요구사항
|
||||
## 📖 추가 문서
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Python 3.11+
|
||||
- PostgreSQL 15+
|
||||
- [📚 종합 개발 가이드](COMPREHENSIVE_GUIDE.md) - 상세한 개발 가이드
|
||||
- [🏠 시놀로지 설치](SYNOLOGY_INSTALL.md) - NAS 설치 방법
|
||||
|
||||
## 📖 자세한 가이드
|
||||
## 🤝 기여하기
|
||||
|
||||
- [종합 개발 가이드](COMPREHENSIVE_GUIDE.md)
|
||||
- [시놀로지 설치 가이드](SYNOLOGY_INSTALL.md)
|
||||
1. Fork the Project
|
||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 📄 라이선스
|
||||
|
||||
이 프로젝트는 MIT 라이선스 하에 배포됩니다.
|
||||
|
||||
## 📞 문의
|
||||
|
||||
프로젝트 링크: [https://git.hyungi.net/hyungi/Todo-Project](https://git.hyungi.net/hyungi/Todo-Project)
|
||||
|
||||
326
SYNOLOGY_DEPLOYMENT_GUIDE.md
Normal file
326
SYNOLOGY_DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,326 @@
|
||||
# 🚀 Todo-Project 시놀로지 배포 가이드
|
||||
|
||||
## 📋 목차
|
||||
1. [사전 준비](#사전-준비)
|
||||
2. [시놀로지 환경 설정](#시놀로지-환경-설정)
|
||||
3. [프로젝트 배포](#프로젝트-배포)
|
||||
4. [환경 설정](#환경-설정)
|
||||
5. [배포 실행](#배포-실행)
|
||||
6. [접속 및 확인](#접속-및-확인)
|
||||
7. [문제 해결](#문제-해결)
|
||||
8. [유지보수](#유지보수)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 사전 준비
|
||||
|
||||
### 시놀로지 요구사항
|
||||
- **DSM 7.0 이상**
|
||||
- **Docker 패키지 설치**
|
||||
- **Container Manager 설치** (DSM 7.2+) 또는 **Docker 패키지** (DSM 7.1 이하)
|
||||
- **최소 2GB RAM** (권장: 4GB 이상)
|
||||
- **최소 5GB 저장공간**
|
||||
|
||||
### 필요한 포트
|
||||
- **4000**: 프론트엔드 (웹 인터페이스)
|
||||
- **9000**: 백엔드 API
|
||||
- **5432**: PostgreSQL 데이터베이스
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 시놀로지 환경 설정
|
||||
|
||||
### 1. 디렉토리 구조 생성
|
||||
|
||||
SSH 또는 File Station을 통해 다음 디렉토리를 생성하세요:
|
||||
|
||||
```bash
|
||||
# 이미지 업로드 저장소 (volume1 - 빠른 액세스)
|
||||
sudo mkdir -p /volume1/todo-project/uploads
|
||||
sudo chmod 755 /volume1/todo-project/uploads
|
||||
|
||||
# 설정 및 데이터베이스 저장소 (volume3)
|
||||
sudo mkdir -p /volume3/docker/todo-project/config
|
||||
sudo mkdir -p /volume3/docker/todo-project/postgres
|
||||
sudo mkdir -p /volume3/docker/todo-project/app
|
||||
|
||||
# 권한 설정
|
||||
sudo chown -R 1000:1000 /volume1/todo-project
|
||||
sudo chown -R 999:999 /volume3/docker/todo-project/postgres
|
||||
sudo chown -R 1000:1000 /volume3/docker/todo-project/config
|
||||
```
|
||||
|
||||
### 2. 방화벽 설정 (선택사항)
|
||||
|
||||
DSM > 제어판 > 보안 > 방화벽에서 다음 포트를 허용하세요:
|
||||
- **4000/TCP** (Todo-Project 웹 인터페이스)
|
||||
- **9000/TCP** (API 서버)
|
||||
|
||||
---
|
||||
|
||||
## 📦 프로젝트 배포
|
||||
|
||||
### 방법 1: Git Clone (권장)
|
||||
|
||||
```bash
|
||||
# 시놀로지에 SSH 접속 후
|
||||
cd /volume3/docker/todo-project/app
|
||||
git clone https://github.com/your-username/Todo-Project.git .
|
||||
|
||||
# 또는 특정 브랜치
|
||||
git clone -b main https://github.com/your-username/Todo-Project.git .
|
||||
```
|
||||
|
||||
### 방법 2: 파일 업로드
|
||||
|
||||
1. **로컬에서 프로젝트 압축**:
|
||||
```bash
|
||||
tar -czf todo-project.tar.gz --exclude='.git' --exclude='node_modules' --exclude='__pycache__' .
|
||||
```
|
||||
|
||||
2. **시놀로지로 업로드**:
|
||||
- File Station을 통해 `/volume3/docker/todo-project/app/`에 업로드
|
||||
- 압축 해제: `tar -xzf todo-project.tar.gz`
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 환경 설정
|
||||
|
||||
### 1. 환경 파일 설정
|
||||
|
||||
```bash
|
||||
cd /volume3/docker/todo-project/app
|
||||
cp env.synology.example .env
|
||||
```
|
||||
|
||||
### 2. 환경 변수 수정
|
||||
|
||||
`.env` 파일을 편집하여 시놀로지 환경에 맞게 설정:
|
||||
|
||||
```bash
|
||||
# 필수 설정 (반드시 변경!)
|
||||
SECRET_KEY=your-very-long-and-random-secret-key-for-production
|
||||
POSTGRES_PASSWORD=your-secure-database-password-123
|
||||
|
||||
# 포트 설정 (필요시 변경)
|
||||
FRONTEND_PORT=4000
|
||||
BACKEND_PORT=9000
|
||||
DATABASE_PORT=5432
|
||||
|
||||
# 시놀로지 볼륨 경로 (기본값 사용 권장)
|
||||
SYNOLOGY_UPLOADS_PATH=/volume1/todo-project/uploads
|
||||
SYNOLOGY_CONFIG_PATH=/volume3/docker/todo-project/config
|
||||
SYNOLOGY_DB_PATH=/volume3/docker/todo-project/postgres
|
||||
|
||||
# CORS 설정 (시놀로지 IP로 변경)
|
||||
CORS_ORIGINS=["http://192.168.1.100:4000", "http://localhost:4000"]
|
||||
|
||||
# 프로덕션 설정
|
||||
DEBUG=false
|
||||
```
|
||||
|
||||
### 3. 마이그레이션 파일 복사
|
||||
|
||||
```bash
|
||||
# 데이터베이스 초기화 스크립트 복사
|
||||
cp -r backend/migrations/* /volume3/docker/todo-project/config/migrations/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 배포 실행
|
||||
|
||||
### 1. Docker Compose 실행
|
||||
|
||||
```bash
|
||||
cd /volume3/docker/todo-project/app
|
||||
|
||||
# 이미지 빌드 및 컨테이너 시작
|
||||
docker-compose up -d --build
|
||||
|
||||
# 로그 확인
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 2. 컨테이너 상태 확인
|
||||
|
||||
```bash
|
||||
# 컨테이너 상태 확인
|
||||
docker-compose ps
|
||||
|
||||
# 개별 서비스 로그 확인
|
||||
docker-compose logs backend
|
||||
docker-compose logs frontend
|
||||
docker-compose logs database
|
||||
```
|
||||
|
||||
### 3. 헬스체크 확인
|
||||
|
||||
```bash
|
||||
# 백엔드 API 확인
|
||||
curl http://localhost:9000/health
|
||||
|
||||
# 데이터베이스 연결 확인
|
||||
docker-compose exec database pg_isready -U todo_user -d todo_db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 접속 및 확인
|
||||
|
||||
### 1. 웹 인터페이스 접속
|
||||
|
||||
브라우저에서 다음 주소로 접속:
|
||||
- **로컬**: `http://시놀로지IP:4000`
|
||||
- **예시**: `http://192.168.1.100:4000`
|
||||
|
||||
### 2. 초기 설정
|
||||
|
||||
1. **관리자 계정 생성**: 최초 접속 시 관리자 계정을 설정합니다
|
||||
2. **로그인**: 생성한 계정으로 로그인
|
||||
3. **기능 테스트**: 메모 작성, 이미지 업로드 등 기본 기능 확인
|
||||
|
||||
### 3. 리버스 프록시 설정 (선택사항)
|
||||
|
||||
DSM > 제어판 > 로그인 포털 > 고급 > 리버스 프록시에서:
|
||||
|
||||
```
|
||||
소스:
|
||||
- 프로토콜: HTTPS
|
||||
- 호스트 이름: your-domain.synology.me
|
||||
- 포트: 443
|
||||
- 경로: /todo
|
||||
|
||||
대상:
|
||||
- 프로토콜: HTTP
|
||||
- 호스트 이름: localhost
|
||||
- 포트: 4000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 문제 해결
|
||||
|
||||
### 일반적인 문제들
|
||||
|
||||
#### 1. 컨테이너가 시작되지 않는 경우
|
||||
|
||||
```bash
|
||||
# 로그 확인
|
||||
docker-compose logs
|
||||
|
||||
# 개별 컨테이너 재시작
|
||||
docker-compose restart backend
|
||||
docker-compose restart database
|
||||
```
|
||||
|
||||
#### 2. 데이터베이스 연결 오류
|
||||
|
||||
```bash
|
||||
# 데이터베이스 컨테이너 상태 확인
|
||||
docker-compose exec database pg_isready -U todo_user
|
||||
|
||||
# 환경 변수 확인
|
||||
docker-compose exec backend env | grep DATABASE_URL
|
||||
```
|
||||
|
||||
#### 3. 이미지 업로드 실패
|
||||
|
||||
```bash
|
||||
# 업로드 디렉토리 권한 확인
|
||||
ls -la /volume1/todo-project/uploads/
|
||||
|
||||
# 권한 수정
|
||||
sudo chown -R 1000:1000 /volume1/todo-project/uploads/
|
||||
sudo chmod -R 755 /volume1/todo-project/uploads/
|
||||
```
|
||||
|
||||
#### 4. CORS 오류
|
||||
|
||||
`.env` 파일에서 `CORS_ORIGINS`에 시놀로지 IP 추가:
|
||||
```bash
|
||||
CORS_ORIGINS=["http://192.168.1.100:4000", "https://your-domain.synology.me"]
|
||||
```
|
||||
|
||||
### 로그 확인 명령어
|
||||
|
||||
```bash
|
||||
# 전체 로그
|
||||
docker-compose logs -f
|
||||
|
||||
# 특정 서비스 로그
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f database
|
||||
|
||||
# 최근 로그만
|
||||
docker-compose logs --tail=50 backend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 유지보수
|
||||
|
||||
### 업데이트
|
||||
|
||||
```bash
|
||||
cd /volume3/docker/todo-project/app
|
||||
|
||||
# Git으로 최신 코드 가져오기
|
||||
git pull origin main
|
||||
|
||||
# 컨테이너 재빌드 및 재시작
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### 백업
|
||||
|
||||
```bash
|
||||
# 데이터베이스 백업
|
||||
docker-compose exec database pg_dump -U todo_user todo_db > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 업로드된 이미지 백업
|
||||
tar -czf uploads_backup_$(date +%Y%m%d).tar.gz /volume1/todo-project/uploads/
|
||||
```
|
||||
|
||||
### 모니터링
|
||||
|
||||
```bash
|
||||
# 컨테이너 리소스 사용량
|
||||
docker stats
|
||||
|
||||
# 디스크 사용량
|
||||
df -h /volume1/todo-project/
|
||||
df -h /volume3/docker/todo-project/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음을 확인하세요:
|
||||
|
||||
1. **로그 파일**: `docker-compose logs`
|
||||
2. **포트 충돌**: `netstat -tulpn | grep :4000`
|
||||
3. **디스크 공간**: `df -h`
|
||||
4. **메모리 사용량**: `free -m`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 성능 최적화 팁
|
||||
|
||||
### 1. 볼륨 배치 최적화
|
||||
- **이미지 저장소**: volume1 (SSD 권장) - 빠른 액세스
|
||||
- **데이터베이스**: volume3 (HDD 가능) - 대용량 저장
|
||||
|
||||
### 2. 메모리 설정
|
||||
- 최소 2GB RAM 할당
|
||||
- PostgreSQL shared_buffers 조정
|
||||
|
||||
### 3. 네트워크 최적화
|
||||
- 리버스 프록시 사용으로 HTTPS 적용
|
||||
- CDN 사용 고려 (정적 파일)
|
||||
|
||||
---
|
||||
|
||||
**🎉 축하합니다! Todo-Project가 시놀로지에 성공적으로 배포되었습니다!**
|
||||
@@ -1,285 +0,0 @@
|
||||
# 🏠 시놀로지 NAS 설치 가이드
|
||||
|
||||
## 📋 사전 준비사항
|
||||
|
||||
### 1. 시놀로지 NAS 요구사항
|
||||
- **DSM 7.0 이상**
|
||||
- **Container Manager** 패키지 설치
|
||||
- **Git Server** 패키지 설치 (선택사항)
|
||||
- **최소 2GB RAM** 권장
|
||||
|
||||
### 2. 필요한 포트
|
||||
- **4000**: 프론트엔드 (Nginx)
|
||||
- **8000**: 백엔드 API (FastAPI)
|
||||
- **5432**: PostgreSQL (내부 통신)
|
||||
|
||||
## 🚀 설치 방법
|
||||
|
||||
### 방법 1: SSH를 통한 설치 (권장)
|
||||
|
||||
#### 1단계: SSH 접속
|
||||
```bash
|
||||
ssh admin@[시놀로지_IP주소]
|
||||
```
|
||||
|
||||
#### 2단계: 프로젝트 디렉토리 생성
|
||||
```bash
|
||||
sudo mkdir -p /volume1/docker/todo-project
|
||||
cd /volume1/docker/todo-project
|
||||
```
|
||||
|
||||
#### 3단계: Git 클론
|
||||
```bash
|
||||
sudo git clone https://git.hyungi.net/hyungi/Todo-Project.git .
|
||||
```
|
||||
|
||||
#### 4단계: 환경 설정
|
||||
```bash
|
||||
# 환경 변수 파일 생성
|
||||
sudo cp .env.example .env
|
||||
|
||||
# 환경 변수 편집 (필요시)
|
||||
sudo nano .env
|
||||
```
|
||||
|
||||
#### 5단계: Docker 컨테이너 실행
|
||||
```bash
|
||||
sudo docker-compose up -d
|
||||
```
|
||||
|
||||
#### 6단계: 설치 확인
|
||||
```bash
|
||||
# 컨테이너 상태 확인
|
||||
sudo docker-compose ps
|
||||
|
||||
# 로그 확인
|
||||
sudo docker-compose logs -f
|
||||
```
|
||||
|
||||
### 방법 2: Container Manager GUI 사용
|
||||
|
||||
#### 1단계: Container Manager 열기
|
||||
- DSM → **Package Center** → **Container Manager** 설치/실행
|
||||
|
||||
#### 2단계: 프로젝트 생성
|
||||
- **Project** → **Create**
|
||||
- **Project name**: `todo-project`
|
||||
- **Path**: `/docker/todo-project`
|
||||
|
||||
#### 3단계: 소스 설정
|
||||
- **Source**: `Git Repository`
|
||||
- **Repository URL**: `https://git.hyungi.net/hyungi/Todo-Project.git`
|
||||
- **Branch**: `main`
|
||||
|
||||
#### 4단계: 컨테이너 실행
|
||||
- **Build and run** 클릭
|
||||
- 자동으로 `docker-compose.yml` 파일을 읽어서 실행
|
||||
|
||||
## 🔧 설정 및 최적화
|
||||
|
||||
### 1. 환경 변수 설정 (.env)
|
||||
```bash
|
||||
# 데이터베이스 설정
|
||||
POSTGRES_DB=todo_project
|
||||
POSTGRES_USER=todo_user
|
||||
POSTGRES_PASSWORD=your_secure_password_here
|
||||
|
||||
# JWT 설정
|
||||
SECRET_KEY=your_jwt_secret_key_here
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# 시놀로지 연동 설정
|
||||
SYNOLOGY_DSM_HOST=localhost
|
||||
SYNOLOGY_DSM_PORT=5000
|
||||
SYNOLOGY_DSM_USERNAME=your_username
|
||||
SYNOLOGY_DSM_PASSWORD=your_password
|
||||
|
||||
# 메일 설정 (MailPlus 연동용)
|
||||
MAIL_SERVER=localhost
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your_mail_username
|
||||
MAIL_PASSWORD=your_mail_password
|
||||
MAIL_FROM=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
### 2. 볼륨 매핑 확인
|
||||
```yaml
|
||||
volumes:
|
||||
- /volume1/docker/todo-project/data:/data
|
||||
- todo_uploads:/data/uploads
|
||||
```
|
||||
|
||||
### 3. 포트 포워딩 설정
|
||||
- **제어판** → **외부 액세스** → **라우터 구성**
|
||||
- 포트 4000을 외부에서 접근 가능하도록 설정
|
||||
|
||||
## 🌐 접속 방법
|
||||
|
||||
### 내부 네트워크
|
||||
```
|
||||
http://[시놀로지_IP]:4000
|
||||
```
|
||||
|
||||
### 외부 접속 (DDNS 설정 시)
|
||||
```
|
||||
http://[your-synology-ddns].synology.me:4000
|
||||
```
|
||||
|
||||
## 🔒 보안 설정
|
||||
|
||||
### 1. HTTPS 설정 (Let's Encrypt)
|
||||
```bash
|
||||
# 인증서 디렉토리 생성
|
||||
sudo mkdir -p /volume1/docker/todo-project/ssl
|
||||
|
||||
# docker-compose.yml에 SSL 볼륨 추가
|
||||
volumes:
|
||||
- /volume1/ssl:/etc/ssl/certs
|
||||
```
|
||||
|
||||
### 2. 방화벽 설정
|
||||
- **제어판** → **보안** → **방화벽**
|
||||
- 필요한 포트만 열기: 4000, 8000
|
||||
|
||||
### 3. 사용자 권한 설정
|
||||
```bash
|
||||
# Docker 그룹에 사용자 추가
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
## 📊 모니터링 및 유지보수
|
||||
|
||||
### 1. 로그 확인
|
||||
```bash
|
||||
# 전체 로그
|
||||
sudo docker-compose logs
|
||||
|
||||
# 특정 서비스 로그
|
||||
sudo docker-compose logs frontend
|
||||
sudo docker-compose logs backend
|
||||
sudo docker-compose logs db
|
||||
```
|
||||
|
||||
### 2. 컨테이너 상태 확인
|
||||
```bash
|
||||
# 실행 중인 컨테이너
|
||||
sudo docker-compose ps
|
||||
|
||||
# 리소스 사용량
|
||||
sudo docker stats
|
||||
```
|
||||
|
||||
### 3. 업데이트
|
||||
```bash
|
||||
# 최신 코드 가져오기
|
||||
sudo git pull origin main
|
||||
|
||||
# 컨테이너 재빌드
|
||||
sudo docker-compose down
|
||||
sudo docker-compose up -d --build
|
||||
```
|
||||
|
||||
### 4. 백업
|
||||
```bash
|
||||
# 데이터베이스 백업
|
||||
sudo docker-compose exec db pg_dump -U todo_user todo_project > backup.sql
|
||||
|
||||
# 업로드 파일 백업
|
||||
sudo tar -czf uploads_backup.tar.gz /volume1/docker/todo-project/data/uploads
|
||||
```
|
||||
|
||||
## 🔧 문제 해결
|
||||
|
||||
### 1. 컨테이너가 시작되지 않는 경우
|
||||
```bash
|
||||
# 로그 확인
|
||||
sudo docker-compose logs
|
||||
|
||||
# 포트 충돌 확인
|
||||
sudo netstat -tulpn | grep :4000
|
||||
```
|
||||
|
||||
### 2. 데이터베이스 연결 오류
|
||||
```bash
|
||||
# PostgreSQL 컨테이너 상태 확인
|
||||
sudo docker-compose exec db psql -U todo_user -d todo_project -c "SELECT 1;"
|
||||
```
|
||||
|
||||
### 3. 권한 문제
|
||||
```bash
|
||||
# 파일 권한 수정
|
||||
sudo chown -R 1000:1000 /volume1/docker/todo-project/data
|
||||
```
|
||||
|
||||
### 4. 메모리 부족
|
||||
```bash
|
||||
# 메모리 사용량 확인
|
||||
free -h
|
||||
|
||||
# Docker 메모리 제한 설정 (docker-compose.yml)
|
||||
services:
|
||||
backend:
|
||||
mem_limit: 512m
|
||||
```
|
||||
|
||||
## 📱 PWA 설정
|
||||
|
||||
### 1. HTTPS 필수
|
||||
- PWA 기능을 위해서는 HTTPS 연결이 필요합니다
|
||||
- Let's Encrypt 인증서 설정 권장
|
||||
|
||||
### 2. 홈화면 추가
|
||||
- 모바일에서 Safari/Chrome으로 접속
|
||||
- "홈화면에 추가" 선택
|
||||
- 네이티브 앱처럼 사용 가능
|
||||
|
||||
## 🎯 성능 최적화
|
||||
|
||||
### 1. Nginx 캐싱 설정
|
||||
```nginx
|
||||
location /static/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 데이터베이스 최적화
|
||||
```sql
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_todos_created_at ON todos(created_at);
|
||||
CREATE INDEX idx_todos_status ON todos(status);
|
||||
```
|
||||
|
||||
### 3. 이미지 최적화
|
||||
- 업로드된 이미지는 자동으로 압축됩니다
|
||||
- 최대 1920x1920 해상도로 리사이즈
|
||||
- JPEG 품질 85%로 최적화
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음을 확인해주세요:
|
||||
1. **로그 파일**: `sudo docker-compose logs`
|
||||
2. **시스템 리소스**: `htop` 또는 DSM 리소스 모니터
|
||||
3. **네트워크 연결**: 포트 접근 가능 여부
|
||||
4. **권한 설정**: 파일 및 디렉토리 권한
|
||||
|
||||
---
|
||||
|
||||
## 🚀 빠른 시작 명령어
|
||||
|
||||
```bash
|
||||
# 전체 설치 (한 번에 실행)
|
||||
ssh admin@[시놀로지_IP]
|
||||
sudo mkdir -p /volume1/docker/todo-project
|
||||
cd /volume1/docker/todo-project
|
||||
sudo git clone https://git.hyungi.net/hyungi/Todo-Project.git .
|
||||
sudo cp .env.example .env
|
||||
sudo docker-compose up -d
|
||||
|
||||
# 접속 확인
|
||||
curl http://localhost:4000
|
||||
```
|
||||
|
||||
설치 완료 후 `http://[시놀로지_IP]:4000`으로 접속하여 Todo Project를 사용하세요! 🎉
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ WORKDIR /app
|
||||
# 시스템 패키지 업데이트 및 필요한 패키지 설치
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 의존성 설치
|
||||
|
||||
38
backend/migrations/003_optimize_for_workflow.sql
Normal file
38
backend/migrations/003_optimize_for_workflow.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- 새로운 워크플로우에 맞게 DB 구조 최적화
|
||||
|
||||
-- 1. due_date를 start_date로 변경
|
||||
ALTER TABLE todos RENAME COLUMN due_date TO start_date;
|
||||
|
||||
-- 2. tags 컬럼 제거 (사용하지 않음)
|
||||
ALTER TABLE todos DROP COLUMN IF EXISTS tags;
|
||||
|
||||
-- 3. category 기본값 변경 및 기존 데이터 정리
|
||||
-- 기존 'checklist' 카테고리를 'memo'로 변경
|
||||
UPDATE todos SET category = 'memo' WHERE category = 'checklist';
|
||||
|
||||
-- 기존 'calendar' 카테고리를 'todo'로 변경
|
||||
UPDATE todos SET category = 'todo' WHERE category = 'calendar';
|
||||
|
||||
-- 4. title을 nullable로 변경 (메모의 경우 선택사항)
|
||||
ALTER TABLE todos ALTER COLUMN title DROP NOT NULL;
|
||||
|
||||
-- 5. description을 NOT NULL로 변경 (내용은 필수)
|
||||
UPDATE todos SET description = COALESCE(title, '내용 없음') WHERE description IS NULL OR description = '';
|
||||
ALTER TABLE todos ALTER COLUMN description SET NOT NULL;
|
||||
|
||||
-- 6. category 기본값을 'memo'로 설정
|
||||
ALTER TABLE todos ALTER COLUMN category SET DEFAULT 'memo';
|
||||
|
||||
-- 7. 불필요한 인덱스 정리 및 새로운 인덱스 추가
|
||||
-- 기존 due_date 인덱스 제거 (컬럼명이 변경됨)
|
||||
DROP INDEX IF EXISTS idx_todos_due_date;
|
||||
|
||||
-- 새로운 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_start_date ON todos(start_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_category_status ON todos(category, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_user_category ON todos(user_id, category);
|
||||
|
||||
-- 8. 성능 최적화를 위한 복합 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_todos_workflow ON todos(user_id, category, status, start_date);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
간단한 캘린더 API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List
|
||||
@@ -32,10 +33,10 @@ async def get_calendar_todos(
|
||||
query = select(Todo).where(
|
||||
and_(
|
||||
Todo.user_id == current_user.id,
|
||||
Todo.due_date >= start_date,
|
||||
Todo.due_date <= end_date
|
||||
Todo.start_date >= start_date,
|
||||
Todo.start_date <= end_date
|
||||
)
|
||||
).order_by(Todo.due_date.asc())
|
||||
).order_by(Todo.start_date.asc())
|
||||
|
||||
result = await db.execute(query)
|
||||
todos = result.scalars().all()
|
||||
@@ -62,7 +63,7 @@ async def get_today_todos(
|
||||
query = select(Todo).where(
|
||||
and_(
|
||||
Todo.user_id == current_user.id,
|
||||
Todo.due_date == today
|
||||
Todo.start_date == today
|
||||
)
|
||||
).order_by(Todo.created_at.desc())
|
||||
|
||||
|
||||
121
backend/src/api/routes/setup.py
Normal file
121
backend/src/api/routes/setup.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
초기 설정 관련 API 라우터
|
||||
- 최초 관리자 계정 설정
|
||||
- 시스템 초기화 상태 확인
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.security import get_password_hash
|
||||
from ...models.user import User
|
||||
from ...schemas.auth import CreateUserRequest
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class InitialSetupRequest(BaseModel):
|
||||
"""초기 설정 요청"""
|
||||
admin_username: str = Field(..., min_length=3, max_length=50, description="관리자 사용자명")
|
||||
admin_email: str = Field(..., description="관리자 이메일")
|
||||
admin_password: str = Field(..., min_length=6, description="관리자 비밀번호")
|
||||
admin_full_name: str = Field(default="Administrator", description="관리자 이름")
|
||||
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
"""설정 상태 응답"""
|
||||
is_setup_required: bool
|
||||
user_count: int
|
||||
|
||||
|
||||
@router.get("/status", response_model=SetupStatusResponse)
|
||||
async def get_setup_status(db: AsyncSession = Depends(get_db)):
|
||||
"""시스템 초기 설정 필요 여부 확인"""
|
||||
try:
|
||||
# 사용자 수 확인
|
||||
result = await db.execute(select(func.count(User.id)))
|
||||
user_count = result.scalar() or 0
|
||||
|
||||
return SetupStatusResponse(
|
||||
is_setup_required=user_count == 0,
|
||||
user_count=user_count
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="설정 상태 확인 중 오류가 발생했습니다."
|
||||
)
|
||||
|
||||
|
||||
@router.post("/initialize")
|
||||
async def initialize_system(
|
||||
setup_data: InitialSetupRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""시스템 초기화 및 관리자 계정 생성"""
|
||||
try:
|
||||
# 이미 사용자가 있는지 확인
|
||||
result = await db.execute(select(func.count(User.id)))
|
||||
user_count = result.scalar() or 0
|
||||
|
||||
if user_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="시스템이 이미 초기화되었습니다."
|
||||
)
|
||||
|
||||
# 사용자명 중복 확인
|
||||
existing_user = await db.execute(
|
||||
select(User).where(User.username == setup_data.admin_username)
|
||||
)
|
||||
if existing_user.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 사용자명입니다."
|
||||
)
|
||||
|
||||
# 이메일 중복 확인
|
||||
existing_email = await db.execute(
|
||||
select(User).where(User.email == setup_data.admin_email)
|
||||
)
|
||||
if existing_email.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미 존재하는 이메일입니다."
|
||||
)
|
||||
|
||||
# 관리자 계정 생성
|
||||
admin_user = User(
|
||||
username=setup_data.admin_username,
|
||||
email=setup_data.admin_email,
|
||||
hashed_password=get_password_hash(setup_data.admin_password),
|
||||
full_name=setup_data.admin_full_name,
|
||||
is_active=True,
|
||||
is_admin=True
|
||||
)
|
||||
|
||||
db.add(admin_user)
|
||||
await db.commit()
|
||||
await db.refresh(admin_user)
|
||||
|
||||
return {
|
||||
"message": "시스템이 성공적으로 초기화되었습니다.",
|
||||
"admin_user": {
|
||||
"id": str(admin_user.id),
|
||||
"username": admin_user.username,
|
||||
"email": admin_user.email,
|
||||
"full_name": admin_user.full_name
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="시스템 초기화 중 오류가 발생했습니다."
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
간단한 Todo API 라우터
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List, Optional
|
||||
@@ -14,11 +14,54 @@ from ...models.user import User
|
||||
from ...models.todo import Todo, TodoStatus
|
||||
from ...schemas.todo import TodoCreate, TodoUpdate, TodoResponse
|
||||
from ..dependencies import get_current_active_user
|
||||
from ...services.file_service import save_image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/todos", tags=["todos"])
|
||||
|
||||
|
||||
@router.post("/upload-image")
|
||||
async def upload_image(
|
||||
image: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
"""이미지 파일 업로드"""
|
||||
try:
|
||||
# 파일 타입 검증
|
||||
if not image.content_type.startswith('image/'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="이미지 파일만 업로드 가능합니다."
|
||||
)
|
||||
|
||||
# 파일 크기 검증 (10MB 제한)
|
||||
content = await image.read()
|
||||
if len(content) > 10 * 1024 * 1024: # 10MB
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="파일 크기는 10MB를 초과할 수 없습니다."
|
||||
)
|
||||
|
||||
# 이미지 저장
|
||||
file_url = save_image(content, image.filename)
|
||||
if not file_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 저장에 실패했습니다."
|
||||
)
|
||||
|
||||
return {"file_url": file_url}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"이미지 업로드 실패: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 업로드 중 오류가 발생했습니다."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Todo CRUD API
|
||||
# ============================================================================
|
||||
@@ -34,15 +77,17 @@ async def create_todo(
|
||||
logger.info(f"Todo 생성 요청 - 사용자: {current_user.username}")
|
||||
logger.info(f"요청 데이터: {todo_data.dict()}")
|
||||
|
||||
# 날짜 문자열 파싱 (한국 시간 형식)
|
||||
parsed_due_date = None
|
||||
if todo_data.due_date:
|
||||
# 시작 날짜 문자열 파싱 (Todo일 때만)
|
||||
parsed_start_date = None
|
||||
if todo_data.start_date and todo_data.category.value == "todo":
|
||||
try:
|
||||
# "2025-09-22T00:00:00+09:00" 형식 파싱
|
||||
parsed_due_date = datetime.fromisoformat(todo_data.due_date)
|
||||
logger.info(f"파싱된 날짜: {parsed_due_date}")
|
||||
# "2025-09-22" 형식 파싱 후 한국 시간으로 변환
|
||||
from datetime import date
|
||||
date_obj = datetime.strptime(todo_data.start_date, "%Y-%m-%d").date()
|
||||
parsed_start_date = datetime.combine(date_obj, datetime.min.time())
|
||||
logger.info(f"파싱된 시작 날짜: {parsed_start_date}")
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid date format: {todo_data.due_date}")
|
||||
logger.warning(f"Invalid date format: {todo_data.start_date}")
|
||||
|
||||
# 이미지 URLs JSON 변환
|
||||
import json
|
||||
@@ -56,9 +101,10 @@ async def create_todo(
|
||||
title=todo_data.title,
|
||||
description=todo_data.description,
|
||||
category=todo_data.category,
|
||||
due_date=parsed_due_date,
|
||||
start_date=parsed_start_date,
|
||||
image_urls=image_urls_json,
|
||||
tags=todo_data.tags
|
||||
board_id=todo_data.board_id,
|
||||
is_board_header=todo_data.is_board_header or False
|
||||
)
|
||||
|
||||
db.add(new_todo)
|
||||
@@ -83,10 +129,11 @@ async def create_todo(
|
||||
"status": new_todo.status,
|
||||
"created_at": new_todo.created_at,
|
||||
"updated_at": new_todo.updated_at,
|
||||
"due_date": new_todo.due_date.isoformat() if new_todo.due_date else None,
|
||||
"start_date": new_todo.start_date.strftime("%Y-%m-%d") if new_todo.start_date else None,
|
||||
"completed_at": new_todo.completed_at,
|
||||
"image_urls": image_urls_list,
|
||||
"tags": new_todo.tags
|
||||
"board_id": str(new_todo.board_id) if new_todo.board_id else None,
|
||||
"is_board_header": new_todo.is_board_header
|
||||
}
|
||||
|
||||
return response_data
|
||||
@@ -146,10 +193,11 @@ async def get_todos(
|
||||
"status": todo.status,
|
||||
"created_at": todo.created_at,
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"start_date": todo.start_date.isoformat() if todo.start_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_urls": image_urls_list,
|
||||
"tags": todo.tags
|
||||
"board_id": str(todo.board_id) if todo.board_id else None,
|
||||
"is_board_header": todo.is_board_header
|
||||
})
|
||||
|
||||
return response_data
|
||||
@@ -201,10 +249,11 @@ async def get_todo(
|
||||
"status": todo.status,
|
||||
"created_at": todo.created_at,
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"start_date": todo.start_date.isoformat() if todo.start_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_urls": image_urls_list,
|
||||
"tags": todo.tags,
|
||||
"board_id": str(todo.board_id) if todo.board_id else None,
|
||||
"is_board_header": todo.is_board_header
|
||||
}
|
||||
|
||||
return response_data
|
||||
@@ -246,7 +295,7 @@ async def update_todo(
|
||||
import json
|
||||
update_data = todo_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == 'due_date' and value:
|
||||
if field == 'start_date' and value:
|
||||
# 날짜 문자열 파싱 (한국 시간 형식)
|
||||
try:
|
||||
parsed_date = datetime.fromisoformat(value)
|
||||
@@ -285,10 +334,11 @@ async def update_todo(
|
||||
"status": todo.status,
|
||||
"created_at": todo.created_at,
|
||||
"updated_at": todo.updated_at,
|
||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
||||
"start_date": todo.start_date.isoformat() if todo.start_date else None,
|
||||
"completed_at": todo.completed_at,
|
||||
"image_urls": image_urls_list,
|
||||
"tags": todo.tags,
|
||||
"board_id": str(todo.board_id) if todo.board_id else None,
|
||||
"is_board_header": todo.is_board_header
|
||||
}
|
||||
|
||||
return response_data
|
||||
|
||||
@@ -24,8 +24,8 @@ class Settings(BaseSettings):
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# CORS 설정
|
||||
ALLOWED_HOSTS: List[str] = ["http://localhost:4000", "http://127.0.0.1:4000"]
|
||||
# CORS 설정 (환경변수로 오버라이드 가능)
|
||||
ALLOWED_HOSTS: List[str] = ["localhost", "127.0.0.1"]
|
||||
ALLOWED_ORIGINS: List[str] = ["http://localhost:4000", "http://127.0.0.1:4000"]
|
||||
|
||||
# 서버 설정
|
||||
|
||||
@@ -68,28 +68,16 @@ async def init_db() -> None:
|
||||
|
||||
|
||||
async def create_admin_user() -> None:
|
||||
"""관리자 계정 생성 (존재하지 않을 경우)"""
|
||||
"""관리자 계정 생성 (존재하지 않을 경우) - 초기 설정 API로 대체됨"""
|
||||
from ..models.user import User
|
||||
from .security import get_password_hash
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 관리자 계정 존재 확인
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == settings.ADMIN_USERNAME)
|
||||
)
|
||||
admin_user = result.scalar_one_or_none()
|
||||
# 사용자 수 확인
|
||||
result = await session.execute(select(func.count(User.id)))
|
||||
user_count = result.scalar() or 0
|
||||
|
||||
if not admin_user:
|
||||
# 관리자 계정 생성
|
||||
admin_user = User(
|
||||
username=settings.ADMIN_USERNAME,
|
||||
email=settings.ADMIN_EMAIL,
|
||||
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
full_name="Administrator"
|
||||
)
|
||||
session.add(admin_user)
|
||||
await session.commit()
|
||||
print(f"관리자 계정이 생성되었습니다: {settings.ADMIN_USERNAME}")
|
||||
if user_count == 0:
|
||||
print("초기 설정이 필요합니다. /api/setup/status 엔드포인트를 확인하세요.")
|
||||
else:
|
||||
print(f"시스템에 {user_count}명의 사용자가 등록되어 있습니다.")
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
캘린더 통합 모듈
|
||||
- 다중 캘린더 제공자 지원 (시놀로지, 애플, 구글 등)
|
||||
- 간결한 API로 Todo 항목을 캘린더에 동기화
|
||||
"""
|
||||
|
||||
from .base import BaseCalendarService, CalendarProvider
|
||||
from .synology import SynologyCalendarService
|
||||
from .apple import AppleCalendarService, create_apple_service, format_todo_for_apple
|
||||
from .router import CalendarRouter, get_calendar_router, setup_calendar_providers
|
||||
|
||||
__all__ = [
|
||||
# 기본 인터페이스
|
||||
"BaseCalendarService",
|
||||
"CalendarProvider",
|
||||
|
||||
# 서비스 구현체
|
||||
"SynologyCalendarService",
|
||||
"AppleCalendarService",
|
||||
|
||||
# 라우터 및 관리
|
||||
"CalendarRouter",
|
||||
"get_calendar_router",
|
||||
"setup_calendar_providers",
|
||||
|
||||
# 편의 함수
|
||||
"create_apple_service",
|
||||
"format_todo_for_apple",
|
||||
]
|
||||
|
||||
# 버전 정보
|
||||
__version__ = "1.0.0"
|
||||
@@ -1,370 +0,0 @@
|
||||
"""
|
||||
Apple iCloud Calendar 서비스 구현
|
||||
- 통합 서비스 파일 기준: 최대 500줄
|
||||
- 간결함 원칙: 한 파일에서 모든 Apple 캘린더 기능 제공
|
||||
"""
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from urllib.parse import urljoin
|
||||
import xml.etree.ElementTree as ET
|
||||
from base64 import b64encode
|
||||
|
||||
from .base import BaseCalendarService, CalendarProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleCalendarService(BaseCalendarService):
|
||||
"""Apple iCloud 캘린더 서비스 (CalDAV 기반)"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = "https://caldav.icloud.com"
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.auth_header: Optional[str] = None
|
||||
self.principal_url: Optional[str] = None
|
||||
self.calendar_home_url: Optional[str] = None
|
||||
|
||||
async def authenticate(self, credentials: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Apple ID 및 앱 전용 암호로 인증
|
||||
credentials: {"apple_id": "user@icloud.com", "app_password": "xxxx-xxxx-xxxx-xxxx"}
|
||||
"""
|
||||
try:
|
||||
apple_id = credentials.get("apple_id")
|
||||
app_password = credentials.get("app_password")
|
||||
|
||||
if not apple_id or not app_password:
|
||||
logger.error("Apple ID 또는 앱 전용 암호가 누락됨")
|
||||
return False
|
||||
|
||||
# Basic Auth 헤더 생성
|
||||
auth_string = f"{apple_id}:{app_password}"
|
||||
auth_bytes = auth_string.encode('utf-8')
|
||||
self.auth_header = f"Basic {b64encode(auth_bytes).decode('utf-8')}"
|
||||
|
||||
# HTTP 세션 생성
|
||||
self.session = aiohttp.ClientSession(
|
||||
headers={"Authorization": self.auth_header}
|
||||
)
|
||||
|
||||
# Principal URL 찾기
|
||||
if not await self._discover_principal():
|
||||
return False
|
||||
|
||||
# Calendar Home URL 찾기
|
||||
if not await self._discover_calendar_home():
|
||||
return False
|
||||
|
||||
logger.info(f"Apple 캘린더 인증 성공: {apple_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Apple 캘린더 인증 실패: {e}")
|
||||
return False
|
||||
|
||||
async def _discover_principal(self) -> bool:
|
||||
"""Principal URL 검색 (CalDAV 표준)"""
|
||||
try:
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:current-user-principal />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
async with self.session.request(
|
||||
"PROPFIND",
|
||||
self.base_url,
|
||||
data=propfind_body,
|
||||
headers={"Content-Type": "application/xml", "Depth": "0"}
|
||||
) as response:
|
||||
|
||||
if response.status != 207:
|
||||
logger.error(f"Principal 검색 실패: {response.status}")
|
||||
return False
|
||||
|
||||
xml_content = await response.text()
|
||||
root = ET.fromstring(xml_content)
|
||||
|
||||
# Principal URL 추출
|
||||
for elem in root.iter():
|
||||
if elem.tag.endswith("current-user-principal"):
|
||||
href = elem.find(".//{DAV:}href")
|
||||
if href is not None:
|
||||
self.principal_url = urljoin(self.base_url, href.text)
|
||||
return True
|
||||
|
||||
logger.error("Principal URL을 찾을 수 없음")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Principal 검색 중 오류: {e}")
|
||||
return False
|
||||
|
||||
async def _discover_calendar_home(self) -> bool:
|
||||
"""Calendar Home URL 검색"""
|
||||
try:
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<c:calendar-home-set />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
async with self.session.request(
|
||||
"PROPFIND",
|
||||
self.principal_url,
|
||||
data=propfind_body,
|
||||
headers={"Content-Type": "application/xml", "Depth": "0"}
|
||||
) as response:
|
||||
|
||||
if response.status != 207:
|
||||
logger.error(f"Calendar Home 검색 실패: {response.status}")
|
||||
return False
|
||||
|
||||
xml_content = await response.text()
|
||||
root = ET.fromstring(xml_content)
|
||||
|
||||
# Calendar Home URL 추출
|
||||
for elem in root.iter():
|
||||
if elem.tag.endswith("calendar-home-set"):
|
||||
href = elem.find(".//{DAV:}href")
|
||||
if href is not None:
|
||||
self.calendar_home_url = urljoin(self.base_url, href.text)
|
||||
return True
|
||||
|
||||
logger.error("Calendar Home URL을 찾을 수 없음")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Calendar Home 검색 중 오류: {e}")
|
||||
return False
|
||||
|
||||
async def get_calendars(self) -> List[Dict[str, Any]]:
|
||||
"""사용 가능한 캘린더 목록 조회"""
|
||||
try:
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
<d:displayname />
|
||||
<d:resourcetype />
|
||||
<c:calendar-description />
|
||||
<c:supported-calendar-component-set />
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
async with self.session.request(
|
||||
"PROPFIND",
|
||||
self.calendar_home_url,
|
||||
data=propfind_body,
|
||||
headers={"Content-Type": "application/xml", "Depth": "1"}
|
||||
) as response:
|
||||
|
||||
if response.status != 207:
|
||||
logger.error(f"캘린더 목록 조회 실패: {response.status}")
|
||||
return []
|
||||
|
||||
xml_content = await response.text()
|
||||
return self._parse_calendars(xml_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 목록 조회 중 오류: {e}")
|
||||
return []
|
||||
|
||||
def _parse_calendars(self, xml_content: str) -> List[Dict[str, Any]]:
|
||||
"""캘린더 XML 응답 파싱"""
|
||||
calendars = []
|
||||
root = ET.fromstring(xml_content)
|
||||
|
||||
for response in root.findall(".//{DAV:}response"):
|
||||
# 캘린더인지 확인
|
||||
resourcetype = response.find(".//{DAV:}resourcetype")
|
||||
if resourcetype is None:
|
||||
continue
|
||||
|
||||
is_calendar = resourcetype.find(".//{urn:ietf:params:xml:ns:caldav}calendar") is not None
|
||||
if not is_calendar:
|
||||
continue
|
||||
|
||||
# 캘린더 정보 추출
|
||||
href_elem = response.find(".//{DAV:}href")
|
||||
name_elem = response.find(".//{DAV:}displayname")
|
||||
desc_elem = response.find(".//{urn:ietf:params:xml:ns:caldav}calendar-description")
|
||||
|
||||
if href_elem is not None and name_elem is not None:
|
||||
calendar = {
|
||||
"id": href_elem.text.split("/")[-2], # URL에서 ID 추출
|
||||
"name": name_elem.text or "이름 없음",
|
||||
"description": desc_elem.text if desc_elem is not None else "",
|
||||
"url": urljoin(self.base_url, href_elem.text),
|
||||
"provider": CalendarProvider.APPLE.value
|
||||
}
|
||||
calendars.append(calendar)
|
||||
|
||||
return calendars
|
||||
|
||||
async def create_event(self, calendar_id: str, event_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""이벤트 생성 (iCalendar 형식)"""
|
||||
try:
|
||||
# iCalendar 이벤트 생성
|
||||
ics_content = self._create_ics_event(event_data)
|
||||
|
||||
# 이벤트 URL 생성 (UUID 기반)
|
||||
import uuid
|
||||
event_id = str(uuid.uuid4())
|
||||
event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics"
|
||||
|
||||
# PUT 요청으로 이벤트 생성
|
||||
async with self.session.put(
|
||||
event_url,
|
||||
data=ics_content,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"}
|
||||
) as response:
|
||||
|
||||
if response.status not in [201, 204]:
|
||||
logger.error(f"이벤트 생성 실패: {response.status}")
|
||||
return {}
|
||||
|
||||
logger.info(f"Apple 캘린더 이벤트 생성 성공: {event_id}")
|
||||
return {
|
||||
"id": event_id,
|
||||
"url": event_url,
|
||||
"provider": CalendarProvider.APPLE.value
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Apple 캘린더 이벤트 생성 중 오류: {e}")
|
||||
return {}
|
||||
|
||||
def _create_ics_event(self, event_data: Dict[str, Any]) -> str:
|
||||
"""iCalendar 형식 이벤트 생성"""
|
||||
title = event_data.get("title", "제목 없음")
|
||||
description = event_data.get("description", "")
|
||||
start_time = event_data.get("start_time")
|
||||
end_time = event_data.get("end_time")
|
||||
|
||||
# 시간 형식 변환
|
||||
if isinstance(start_time, datetime):
|
||||
start_str = start_time.strftime("%Y%m%dT%H%M%SZ")
|
||||
else:
|
||||
start_str = datetime.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
if isinstance(end_time, datetime):
|
||||
end_str = end_time.strftime("%Y%m%dT%H%M%SZ")
|
||||
else:
|
||||
end_str = (datetime.now() + timedelta(hours=1)).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
# iCalendar 내용 생성
|
||||
ics_content = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Todo-Project//Apple Calendar//KO
|
||||
BEGIN:VEVENT
|
||||
UID:{event_data.get('uid', str(uuid.uuid4()))}
|
||||
DTSTART:{start_str}
|
||||
DTEND:{end_str}
|
||||
SUMMARY:{title}
|
||||
DESCRIPTION:{description}
|
||||
CREATED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
return ics_content
|
||||
|
||||
async def update_event(self, event_id: str, event_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""이벤트 수정"""
|
||||
try:
|
||||
# 기존 이벤트 URL 구성
|
||||
calendar_id = event_data.get("calendar_id", "calendar")
|
||||
event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics"
|
||||
|
||||
# 수정된 iCalendar 내용 생성
|
||||
ics_content = self._create_ics_event(event_data)
|
||||
|
||||
# PUT 요청으로 이벤트 수정
|
||||
async with self.session.put(
|
||||
event_url,
|
||||
data=ics_content,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"}
|
||||
) as response:
|
||||
|
||||
if response.status not in [200, 204]:
|
||||
logger.error(f"이벤트 수정 실패: {response.status}")
|
||||
return {}
|
||||
|
||||
logger.info(f"Apple 캘린더 이벤트 수정 성공: {event_id}")
|
||||
return {
|
||||
"id": event_id,
|
||||
"url": event_url,
|
||||
"provider": CalendarProvider.APPLE.value
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Apple 캘린더 이벤트 수정 중 오류: {e}")
|
||||
return {}
|
||||
|
||||
async def delete_event(self, event_id: str, calendar_id: str = "calendar") -> bool:
|
||||
"""이벤트 삭제"""
|
||||
try:
|
||||
# 이벤트 URL 구성
|
||||
event_url = f"{self.calendar_home_url}{calendar_id}/{event_id}.ics"
|
||||
|
||||
# DELETE 요청
|
||||
async with self.session.delete(event_url) as response:
|
||||
if response.status not in [200, 204, 404]:
|
||||
logger.error(f"이벤트 삭제 실패: {response.status}")
|
||||
return False
|
||||
|
||||
logger.info(f"Apple 캘린더 이벤트 삭제 성공: {event_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Apple 캘린더 이벤트 삭제 중 오류: {e}")
|
||||
return False
|
||||
|
||||
async def close(self):
|
||||
"""세션 정리"""
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
self.session = None
|
||||
|
||||
def __del__(self):
|
||||
"""소멸자에서 세션 정리"""
|
||||
if self.session and not self.session.closed:
|
||||
asyncio.create_task(self.close())
|
||||
|
||||
|
||||
# 편의 함수들
|
||||
async def create_apple_service(apple_id: str, app_password: str) -> Optional[AppleCalendarService]:
|
||||
"""Apple 캘린더 서비스 생성 및 인증"""
|
||||
service = AppleCalendarService()
|
||||
|
||||
credentials = {
|
||||
"apple_id": apple_id,
|
||||
"app_password": app_password
|
||||
}
|
||||
|
||||
if await service.authenticate(credentials):
|
||||
return service
|
||||
else:
|
||||
await service.close()
|
||||
return None
|
||||
|
||||
|
||||
def format_todo_for_apple(todo_item) -> Dict[str, Any]:
|
||||
"""Todo 아이템을 Apple 캘린더 이벤트 형식으로 변환"""
|
||||
import uuid
|
||||
|
||||
return {
|
||||
"uid": str(uuid.uuid4()),
|
||||
"title": f"📋 {todo_item.content}",
|
||||
"description": f"Todo 항목\n상태: {todo_item.status}\n생성일: {todo_item.created_at}",
|
||||
"start_time": todo_item.start_date or todo_item.created_at,
|
||||
"end_time": (todo_item.start_date or todo_item.created_at) + timedelta(
|
||||
minutes=todo_item.estimated_minutes or 30
|
||||
),
|
||||
"categories": ["todo", "업무"]
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
"""
|
||||
캘린더 서비스 기본 인터페이스 및 추상화
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
import uuid
|
||||
|
||||
|
||||
class CalendarProvider(Enum):
|
||||
"""캘린더 제공자 열거형"""
|
||||
SYNOLOGY = "synology"
|
||||
APPLE = "apple"
|
||||
GOOGLE = "google"
|
||||
CALDAV = "caldav" # 일반 CalDAV 서버
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarInfo:
|
||||
"""캘린더 정보"""
|
||||
id: str
|
||||
name: str
|
||||
color: str
|
||||
description: Optional[str] = None
|
||||
provider: CalendarProvider = CalendarProvider.CALDAV
|
||||
is_default: bool = False
|
||||
is_writable: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarEvent:
|
||||
"""캘린더 이벤트"""
|
||||
id: Optional[str] = None
|
||||
title: str = ""
|
||||
description: Optional[str] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
all_day: bool = False
|
||||
location: Optional[str] = None
|
||||
categories: List[str] = None
|
||||
color: Optional[str] = None
|
||||
reminder_minutes: Optional[int] = None
|
||||
status: str = "TENTATIVE" # TENTATIVE, CONFIRMED, CANCELLED
|
||||
|
||||
def __post_init__(self):
|
||||
if self.categories is None:
|
||||
self.categories = []
|
||||
if self.id is None:
|
||||
self.id = str(uuid.uuid4())
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalendarCredentials:
|
||||
"""캘린더 인증 정보"""
|
||||
provider: CalendarProvider
|
||||
server_url: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
app_password: Optional[str] = None # Apple 앱 전용 비밀번호
|
||||
oauth_token: Optional[str] = None # OAuth 토큰
|
||||
additional_params: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.additional_params is None:
|
||||
self.additional_params = {}
|
||||
|
||||
|
||||
class CalendarServiceError(Exception):
|
||||
"""캘린더 서비스 오류"""
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationError(CalendarServiceError):
|
||||
"""인증 오류"""
|
||||
pass
|
||||
|
||||
|
||||
class CalendarNotFoundError(CalendarServiceError):
|
||||
"""캘린더를 찾을 수 없음"""
|
||||
pass
|
||||
|
||||
|
||||
class EventNotFoundError(CalendarServiceError):
|
||||
"""이벤트를 찾을 수 없음"""
|
||||
pass
|
||||
|
||||
|
||||
class BaseCalendarService(ABC):
|
||||
"""캘린더 서비스 기본 인터페이스"""
|
||||
|
||||
def __init__(self, credentials: CalendarCredentials):
|
||||
self.credentials = credentials
|
||||
self.provider = credentials.provider
|
||||
self._authenticated = False
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
"""인증 상태 확인"""
|
||||
return self._authenticated
|
||||
|
||||
@abstractmethod
|
||||
async def authenticate(self) -> bool:
|
||||
"""
|
||||
인증 수행
|
||||
|
||||
Returns:
|
||||
bool: 인증 성공 여부
|
||||
|
||||
Raises:
|
||||
AuthenticationError: 인증 실패 시
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_calendars(self) -> List[CalendarInfo]:
|
||||
"""
|
||||
사용 가능한 캘린더 목록 조회
|
||||
|
||||
Returns:
|
||||
List[CalendarInfo]: 캘린더 목록
|
||||
|
||||
Raises:
|
||||
CalendarServiceError: 조회 실패 시
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent:
|
||||
"""
|
||||
이벤트 생성
|
||||
|
||||
Args:
|
||||
calendar_id: 캘린더 ID
|
||||
event: 생성할 이벤트 정보
|
||||
|
||||
Returns:
|
||||
CalendarEvent: 생성된 이벤트 (ID 포함)
|
||||
|
||||
Raises:
|
||||
CalendarNotFoundError: 캘린더를 찾을 수 없음
|
||||
CalendarServiceError: 생성 실패
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent:
|
||||
"""
|
||||
이벤트 수정
|
||||
|
||||
Args:
|
||||
calendar_id: 캘린더 ID
|
||||
event: 수정할 이벤트 정보 (ID 포함)
|
||||
|
||||
Returns:
|
||||
CalendarEvent: 수정된 이벤트
|
||||
|
||||
Raises:
|
||||
EventNotFoundError: 이벤트를 찾을 수 없음
|
||||
CalendarServiceError: 수정 실패
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete_event(self, calendar_id: str, event_id: str) -> bool:
|
||||
"""
|
||||
이벤트 삭제
|
||||
|
||||
Args:
|
||||
calendar_id: 캘린더 ID
|
||||
event_id: 삭제할 이벤트 ID
|
||||
|
||||
Returns:
|
||||
bool: 삭제 성공 여부
|
||||
|
||||
Raises:
|
||||
EventNotFoundError: 이벤트를 찾을 수 없음
|
||||
CalendarServiceError: 삭제 실패
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_event(self, calendar_id: str, event_id: str) -> CalendarEvent:
|
||||
"""
|
||||
특정 이벤트 조회
|
||||
|
||||
Args:
|
||||
calendar_id: 캘린더 ID
|
||||
event_id: 이벤트 ID
|
||||
|
||||
Returns:
|
||||
CalendarEvent: 이벤트 정보
|
||||
|
||||
Raises:
|
||||
EventNotFoundError: 이벤트를 찾을 수 없음
|
||||
"""
|
||||
pass
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""
|
||||
연결 테스트
|
||||
|
||||
Returns:
|
||||
Dict: 테스트 결과
|
||||
"""
|
||||
try:
|
||||
success = await self.authenticate()
|
||||
if success:
|
||||
calendars = await self.get_calendars()
|
||||
return {
|
||||
"status": "success",
|
||||
"provider": self.provider.value,
|
||||
"calendar_count": len(calendars),
|
||||
"calendars": [{"id": cal.id, "name": cal.name} for cal in calendars[:3]] # 처음 3개만
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "failed",
|
||||
"provider": self.provider.value,
|
||||
"error": "Authentication failed"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"provider": self.provider.value,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
def _ensure_authenticated(self):
|
||||
"""인증 상태 확인 (내부 사용)"""
|
||||
if not self._authenticated:
|
||||
raise AuthenticationError(f"{self.provider.value} 서비스에 인증되지 않았습니다.")
|
||||
|
||||
|
||||
class TodoEventConverter:
|
||||
"""Todo 아이템을 캘린더 이벤트로 변환하는 유틸리티"""
|
||||
|
||||
@staticmethod
|
||||
def todo_to_event(todo_item, provider: CalendarProvider) -> CalendarEvent:
|
||||
"""
|
||||
할일을 캘린더 이벤트로 변환
|
||||
|
||||
Args:
|
||||
todo_item: Todo 아이템
|
||||
provider: 캘린더 제공자
|
||||
|
||||
Returns:
|
||||
CalendarEvent: 변환된 이벤트
|
||||
"""
|
||||
# 상태별 아이콘 및 색상
|
||||
status_icons = {
|
||||
"draft": "📝",
|
||||
"scheduled": "📋",
|
||||
"active": "🔥",
|
||||
"completed": "✅",
|
||||
"delayed": "⏰"
|
||||
}
|
||||
|
||||
status_colors = {
|
||||
"draft": "#9ca3af", # 회색
|
||||
"scheduled": "#6366f1", # 보라색
|
||||
"active": "#f59e0b", # 주황색
|
||||
"completed": "#10b981", # 초록색
|
||||
"delayed": "#ef4444" # 빨간색
|
||||
}
|
||||
|
||||
icon = status_icons.get(todo_item.status, "📋")
|
||||
color = status_colors.get(todo_item.status, "#6366f1")
|
||||
|
||||
# 시작/종료 시간 계산
|
||||
start_time = todo_item.start_date
|
||||
end_time = start_time + timedelta(minutes=todo_item.estimated_minutes or 30)
|
||||
|
||||
# 기본 이벤트 생성
|
||||
event = CalendarEvent(
|
||||
title=f"{icon} {todo_item.content}",
|
||||
description=f"Todo ID: {todo_item.id}\nStatus: {todo_item.status}\nEstimated: {todo_item.estimated_minutes or 30}분",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
categories=["완료" if todo_item.status == "completed" else "todo"],
|
||||
color=color,
|
||||
reminder_minutes=15,
|
||||
status="CONFIRMED" if todo_item.status == "completed" else "TENTATIVE"
|
||||
)
|
||||
|
||||
# 제공자별 커스터마이징
|
||||
if provider == CalendarProvider.APPLE:
|
||||
# 애플 캘린더 특화
|
||||
event.color = "#6366f1" # 보라색으로 통일
|
||||
event.reminder_minutes = 15
|
||||
|
||||
elif provider == CalendarProvider.SYNOLOGY:
|
||||
# 시놀로지 캘린더 특화
|
||||
event.location = "Todo-Project"
|
||||
|
||||
elif provider == CalendarProvider.GOOGLE:
|
||||
# 구글 캘린더 특화
|
||||
event.color = "#4285f4" # 구글 블루
|
||||
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def get_provider_specific_properties(event: CalendarEvent, provider: CalendarProvider) -> Dict[str, Any]:
|
||||
"""
|
||||
제공자별 특화 속성 반환
|
||||
|
||||
Args:
|
||||
event: 캘린더 이벤트
|
||||
provider: 캘린더 제공자
|
||||
|
||||
Returns:
|
||||
Dict: 제공자별 특화 속성
|
||||
"""
|
||||
if provider == CalendarProvider.APPLE:
|
||||
return {
|
||||
"X-APPLE-STRUCTURED-LOCATION": event.location,
|
||||
"X-APPLE-CALENDAR-COLOR": event.color
|
||||
}
|
||||
elif provider == CalendarProvider.SYNOLOGY:
|
||||
return {
|
||||
"PRIORITY": "5",
|
||||
"CLASS": "PRIVATE"
|
||||
}
|
||||
elif provider == CalendarProvider.GOOGLE:
|
||||
return {
|
||||
"colorId": "9", # 파란색
|
||||
"visibility": "private"
|
||||
}
|
||||
else:
|
||||
return {}
|
||||
@@ -1,363 +0,0 @@
|
||||
"""
|
||||
캘린더 라우터 - 다중 캘린더 제공자 중앙 관리
|
||||
- 서비스 클래스 기준: 최대 350줄
|
||||
- 간결함 원칙: 단순한 라우팅과 조합 로직만 포함
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict, List, Any, Optional, Union
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
from .base import BaseCalendarService, CalendarProvider
|
||||
from .synology import SynologyCalendarService
|
||||
from .apple import AppleCalendarService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalendarRouter:
|
||||
"""다중 캘린더 제공자를 관리하는 중앙 라우터"""
|
||||
|
||||
def __init__(self):
|
||||
self.services: Dict[CalendarProvider, BaseCalendarService] = {}
|
||||
self.default_provider: Optional[CalendarProvider] = None
|
||||
|
||||
async def register_provider(
|
||||
self,
|
||||
provider: CalendarProvider,
|
||||
credentials: Dict[str, Any],
|
||||
set_as_default: bool = False
|
||||
) -> bool:
|
||||
"""캘린더 제공자 등록 및 인증"""
|
||||
try:
|
||||
# 서비스 인스턴스 생성
|
||||
service = self._create_service(provider)
|
||||
if not service:
|
||||
logger.error(f"지원하지 않는 캘린더 제공자: {provider}")
|
||||
return False
|
||||
|
||||
# 인증 시도
|
||||
if not await service.authenticate(credentials):
|
||||
logger.error(f"{provider.value} 캘린더 인증 실패")
|
||||
return False
|
||||
|
||||
# 등록 완료
|
||||
self.services[provider] = service
|
||||
|
||||
if set_as_default or not self.default_provider:
|
||||
self.default_provider = provider
|
||||
|
||||
logger.info(f"{provider.value} 캘린더 제공자 등록 완료")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 제공자 등록 중 오류: {e}")
|
||||
return False
|
||||
|
||||
def _create_service(self, provider: CalendarProvider) -> Optional[BaseCalendarService]:
|
||||
"""제공자별 서비스 인스턴스 생성"""
|
||||
service_map = {
|
||||
CalendarProvider.SYNOLOGY: SynologyCalendarService,
|
||||
CalendarProvider.APPLE: AppleCalendarService,
|
||||
# 추후 확장: CalendarProvider.GOOGLE: GoogleCalendarService,
|
||||
}
|
||||
|
||||
service_class = service_map.get(provider)
|
||||
return service_class() if service_class else None
|
||||
|
||||
async def get_all_calendars(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""모든 등록된 제공자의 캘린더 목록 조회"""
|
||||
all_calendars = {}
|
||||
|
||||
for provider, service in self.services.items():
|
||||
try:
|
||||
calendars = await service.get_calendars()
|
||||
all_calendars[provider.value] = calendars
|
||||
logger.info(f"{provider.value}: {len(calendars)}개 캘린더 발견")
|
||||
except Exception as e:
|
||||
logger.error(f"{provider.value} 캘린더 목록 조회 실패: {e}")
|
||||
all_calendars[provider.value] = []
|
||||
|
||||
return all_calendars
|
||||
|
||||
async def get_calendars(self, provider: Optional[CalendarProvider] = None) -> List[Dict[str, Any]]:
|
||||
"""특정 제공자 또는 기본 제공자의 캘린더 목록 조회"""
|
||||
target_provider = provider or self.default_provider
|
||||
|
||||
if not target_provider or target_provider not in self.services:
|
||||
logger.error(f"캘린더 제공자를 찾을 수 없음: {target_provider}")
|
||||
return []
|
||||
|
||||
try:
|
||||
return await self.services[target_provider].get_calendars()
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 목록 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
async def create_event(
|
||||
self,
|
||||
calendar_id: str,
|
||||
event_data: Dict[str, Any],
|
||||
provider: Optional[CalendarProvider] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""이벤트 생성 (특정 제공자 또는 기본 제공자)"""
|
||||
target_provider = provider or self.default_provider
|
||||
|
||||
if not target_provider or target_provider not in self.services:
|
||||
logger.error(f"캘린더 제공자를 찾을 수 없음: {target_provider}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
result = await self.services[target_provider].create_event(calendar_id, event_data)
|
||||
result["provider"] = target_provider.value
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 생성 실패: {e}")
|
||||
return {}
|
||||
|
||||
async def create_event_multi(
|
||||
self,
|
||||
calendar_configs: List[Dict[str, Any]],
|
||||
event_data: Dict[str, Any]
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
여러 캘린더에 동시 이벤트 생성
|
||||
calendar_configs: [{"provider": "synology", "calendar_id": "personal"}, ...]
|
||||
"""
|
||||
results = {"success": [], "failed": []}
|
||||
|
||||
# 병렬 처리를 위한 태스크 생성
|
||||
tasks = []
|
||||
for config in calendar_configs:
|
||||
provider_str = config.get("provider")
|
||||
calendar_id = config.get("calendar_id")
|
||||
|
||||
try:
|
||||
provider = CalendarProvider(provider_str)
|
||||
if provider in self.services:
|
||||
task = self._create_event_task(provider, calendar_id, event_data, config)
|
||||
tasks.append(task)
|
||||
else:
|
||||
results["failed"].append({
|
||||
"provider": provider_str,
|
||||
"calendar_id": calendar_id,
|
||||
"error": "제공자를 찾을 수 없음"
|
||||
})
|
||||
except ValueError:
|
||||
results["failed"].append({
|
||||
"provider": provider_str,
|
||||
"calendar_id": calendar_id,
|
||||
"error": "지원하지 않는 제공자"
|
||||
})
|
||||
|
||||
# 병렬 실행
|
||||
if tasks:
|
||||
task_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for i, result in enumerate(task_results):
|
||||
config = calendar_configs[i]
|
||||
if isinstance(result, Exception):
|
||||
results["failed"].append({
|
||||
"provider": config.get("provider"),
|
||||
"calendar_id": config.get("calendar_id"),
|
||||
"error": str(result)
|
||||
})
|
||||
else:
|
||||
results["success"].append(result)
|
||||
|
||||
return results
|
||||
|
||||
async def _create_event_task(
|
||||
self,
|
||||
provider: CalendarProvider,
|
||||
calendar_id: str,
|
||||
event_data: Dict[str, Any],
|
||||
config: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""이벤트 생성 태스크 (병렬 처리용)"""
|
||||
try:
|
||||
result = await self.services[provider].create_event(calendar_id, event_data)
|
||||
result.update({
|
||||
"provider": provider.value,
|
||||
"calendar_id": calendar_id,
|
||||
"config": config
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"{provider.value} 이벤트 생성 실패: {e}")
|
||||
|
||||
async def update_event(
|
||||
self,
|
||||
event_id: str,
|
||||
event_data: Dict[str, Any],
|
||||
provider: Optional[CalendarProvider] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""이벤트 수정"""
|
||||
target_provider = provider or self.default_provider
|
||||
|
||||
if not target_provider or target_provider not in self.services:
|
||||
logger.error(f"캘린더 제공자를 찾을 수 없음: {target_provider}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
result = await self.services[target_provider].update_event(event_id, event_data)
|
||||
result["provider"] = target_provider.value
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 수정 실패: {e}")
|
||||
return {}
|
||||
|
||||
async def delete_event(
|
||||
self,
|
||||
event_id: str,
|
||||
provider: Optional[CalendarProvider] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""이벤트 삭제"""
|
||||
target_provider = provider or self.default_provider
|
||||
|
||||
if not target_provider or target_provider not in self.services:
|
||||
logger.error(f"캘린더 제공자를 찾을 수 없음: {target_provider}")
|
||||
return False
|
||||
|
||||
try:
|
||||
return await self.services[target_provider].delete_event(event_id, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 삭제 실패: {e}")
|
||||
return False
|
||||
|
||||
async def sync_todo_to_calendars(
|
||||
self,
|
||||
todo_item,
|
||||
calendar_configs: Optional[List[Dict[str, Any]]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Todo 아이템을 여러 캘린더에 동기화"""
|
||||
if not calendar_configs:
|
||||
# 기본 제공자만 사용
|
||||
if not self.default_provider:
|
||||
logger.error("기본 캘린더 제공자가 설정되지 않음")
|
||||
return {"success": [], "failed": []}
|
||||
|
||||
calendar_configs = [{
|
||||
"provider": self.default_provider.value,
|
||||
"calendar_id": "default"
|
||||
}]
|
||||
|
||||
# Todo를 캘린더 이벤트 형식으로 변환
|
||||
event_data = self._format_todo_event(todo_item)
|
||||
|
||||
# 여러 캘린더에 생성
|
||||
return await self.create_event_multi(calendar_configs, event_data)
|
||||
|
||||
def _format_todo_event(self, todo_item) -> Dict[str, Any]:
|
||||
"""Todo 아이템을 캘린더 이벤트 형식으로 변환"""
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
|
||||
# 상태별 태그 설정
|
||||
status_tag = "완료" if todo_item.status == "completed" else "todo"
|
||||
|
||||
return {
|
||||
"uid": str(uuid.uuid4()),
|
||||
"title": f"📋 {todo_item.content}",
|
||||
"description": f"Todo 항목\n상태: {status_tag}\n생성일: {todo_item.created_at}",
|
||||
"start_time": todo_item.start_date or todo_item.created_at,
|
||||
"end_time": (todo_item.start_date or todo_item.created_at) + timedelta(
|
||||
minutes=todo_item.estimated_minutes or 30
|
||||
),
|
||||
"categories": [status_tag, "업무"],
|
||||
"todo_id": todo_item.id
|
||||
}
|
||||
|
||||
def get_registered_providers(self) -> List[str]:
|
||||
"""등록된 캘린더 제공자 목록 반환"""
|
||||
return [provider.value for provider in self.services.keys()]
|
||||
|
||||
def set_default_provider(self, provider: CalendarProvider) -> bool:
|
||||
"""기본 캘린더 제공자 설정"""
|
||||
if provider in self.services:
|
||||
self.default_provider = provider
|
||||
logger.info(f"기본 캘린더 제공자 변경: {provider.value}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"등록되지 않은 제공자: {provider.value}")
|
||||
return False
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""모든 등록된 캘린더 서비스 상태 확인"""
|
||||
health_status = {
|
||||
"total_providers": len(self.services),
|
||||
"default_provider": self.default_provider.value if self.default_provider else None,
|
||||
"providers": {}
|
||||
}
|
||||
|
||||
for provider, service in self.services.items():
|
||||
try:
|
||||
# 간단한 캘린더 목록 조회로 상태 확인
|
||||
calendars = await service.get_calendars()
|
||||
health_status["providers"][provider.value] = {
|
||||
"status": "healthy",
|
||||
"calendar_count": len(calendars)
|
||||
}
|
||||
except Exception as e:
|
||||
health_status["providers"][provider.value] = {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
return health_status
|
||||
|
||||
async def close_all(self):
|
||||
"""모든 캘린더 서비스 연결 종료"""
|
||||
for provider, service in self.services.items():
|
||||
try:
|
||||
if hasattr(service, 'close'):
|
||||
await service.close()
|
||||
logger.info(f"{provider.value} 캘린더 서비스 연결 종료")
|
||||
except Exception as e:
|
||||
logger.error(f"{provider.value} 연결 종료 중 오류: {e}")
|
||||
|
||||
self.services.clear()
|
||||
self.default_provider = None
|
||||
|
||||
|
||||
# 전역 라우터 인스턴스 (싱글톤 패턴)
|
||||
_calendar_router: Optional[CalendarRouter] = None
|
||||
|
||||
|
||||
def get_calendar_router() -> CalendarRouter:
|
||||
"""캘린더 라우터 싱글톤 인스턴스 반환"""
|
||||
global _calendar_router
|
||||
if _calendar_router is None:
|
||||
_calendar_router = CalendarRouter()
|
||||
return _calendar_router
|
||||
|
||||
|
||||
async def setup_calendar_providers(providers_config: Dict[str, Dict[str, Any]]) -> CalendarRouter:
|
||||
"""
|
||||
캘린더 제공자들을 일괄 설정
|
||||
providers_config: {
|
||||
"synology": {"credentials": {...}, "default": True},
|
||||
"apple": {"credentials": {...}, "default": False}
|
||||
}
|
||||
"""
|
||||
router = get_calendar_router()
|
||||
|
||||
for provider_name, config in providers_config.items():
|
||||
try:
|
||||
provider = CalendarProvider(provider_name)
|
||||
credentials = config.get("credentials", {})
|
||||
is_default = config.get("default", False)
|
||||
|
||||
success = await router.register_provider(provider, credentials, is_default)
|
||||
if success:
|
||||
logger.info(f"{provider_name} 캘린더 설정 완료")
|
||||
else:
|
||||
logger.error(f"{provider_name} 캘린더 설정 실패")
|
||||
|
||||
except ValueError:
|
||||
logger.error(f"지원하지 않는 캘린더 제공자: {provider_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"{provider_name} 캘린더 설정 중 오류: {e}")
|
||||
|
||||
return router
|
||||
@@ -1,401 +0,0 @@
|
||||
"""
|
||||
시놀로지 캘린더 서비스 구현
|
||||
"""
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import caldav
|
||||
from caldav.lib.error import AuthorizationError, NotFoundError
|
||||
import logging
|
||||
|
||||
from .base import (
|
||||
BaseCalendarService, CalendarProvider, CalendarInfo, CalendarEvent,
|
||||
CalendarCredentials, CalendarServiceError, AuthenticationError,
|
||||
CalendarNotFoundError, EventNotFoundError
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SynologyCalendarService(BaseCalendarService):
|
||||
"""시놀로지 캘린더 서비스"""
|
||||
|
||||
def __init__(self, credentials: CalendarCredentials):
|
||||
super().__init__(credentials)
|
||||
self.dsm_url = credentials.server_url
|
||||
self.username = credentials.username
|
||||
self.password = credentials.password
|
||||
self.session_token = None
|
||||
self.caldav_client = None
|
||||
|
||||
# CalDAV URL 구성
|
||||
if self.dsm_url and self.username:
|
||||
self.caldav_url = f"{self.dsm_url}/caldav/{self.username}/"
|
||||
|
||||
async def authenticate(self) -> bool:
|
||||
"""
|
||||
시놀로지 DSM 및 CalDAV 인증
|
||||
"""
|
||||
try:
|
||||
# 1. DSM API 인증 (선택사항 - 추가 기능용)
|
||||
await self._authenticate_dsm()
|
||||
|
||||
# 2. CalDAV 인증 (메인)
|
||||
await self._authenticate_caldav()
|
||||
|
||||
self._authenticated = True
|
||||
logger.info(f"시놀로지 캘린더 인증 성공: {self.username}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"시놀로지 캘린더 인증 실패: {e}")
|
||||
raise AuthenticationError(f"시놀로지 인증 실패: {str(e)}")
|
||||
|
||||
async def _authenticate_dsm(self) -> Optional[str]:
|
||||
"""DSM API 인증 (추가 기능용)"""
|
||||
if not self.dsm_url:
|
||||
return None
|
||||
|
||||
login_url = f"{self.dsm_url}/webapi/auth.cgi"
|
||||
params = {
|
||||
"api": "SYNO.API.Auth",
|
||||
"version": "3",
|
||||
"method": "login",
|
||||
"account": self.username,
|
||||
"passwd": self.password,
|
||||
"session": "TodoProject",
|
||||
"format": "sid"
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(login_url, params=params, ssl=False) as response:
|
||||
data = await response.json()
|
||||
|
||||
if data.get("success"):
|
||||
self.session_token = data["data"]["sid"]
|
||||
logger.info("DSM API 인증 성공")
|
||||
return self.session_token
|
||||
else:
|
||||
error_code = data.get("error", {}).get("code", "Unknown")
|
||||
raise AuthenticationError(f"DSM 로그인 실패 (코드: {error_code})")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.warning(f"DSM API 인증 실패 (CalDAV는 계속 시도): {e}")
|
||||
return None
|
||||
|
||||
async def _authenticate_caldav(self):
|
||||
"""CalDAV 인증"""
|
||||
try:
|
||||
# CalDAV 클라이언트 생성
|
||||
self.caldav_client = caldav.DAVClient(
|
||||
url=self.caldav_url,
|
||||
username=self.username,
|
||||
password=self.password
|
||||
)
|
||||
|
||||
# 연결 테스트
|
||||
principal = self.caldav_client.principal()
|
||||
calendars = principal.calendars()
|
||||
|
||||
logger.info(f"CalDAV 인증 성공: {len(calendars)}개 캘린더 발견")
|
||||
|
||||
except AuthorizationError as e:
|
||||
raise AuthenticationError(f"CalDAV 인증 실패: 사용자명 또는 비밀번호가 잘못되었습니다")
|
||||
except Exception as e:
|
||||
raise AuthenticationError(f"CalDAV 연결 실패: {str(e)}")
|
||||
|
||||
async def get_calendars(self) -> List[CalendarInfo]:
|
||||
"""캘린더 목록 조회"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
try:
|
||||
principal = self.caldav_client.principal()
|
||||
calendars = principal.calendars()
|
||||
|
||||
calendar_list = []
|
||||
for calendar in calendars:
|
||||
try:
|
||||
# 캘린더 속성 조회
|
||||
props = calendar.get_properties([
|
||||
caldav.dav.DisplayName(),
|
||||
caldav.elements.icalendar.CalendarColor(),
|
||||
caldav.elements.icalendar.CalendarDescription(),
|
||||
])
|
||||
|
||||
name = props.get(caldav.dav.DisplayName.tag, "Unknown Calendar")
|
||||
color = props.get(caldav.elements.icalendar.CalendarColor.tag, "#6366f1")
|
||||
description = props.get(caldav.elements.icalendar.CalendarDescription.tag, "")
|
||||
|
||||
# 색상 형식 정규화
|
||||
if color and not color.startswith('#'):
|
||||
color = f"#{color}"
|
||||
|
||||
calendar_info = CalendarInfo(
|
||||
id=calendar.url,
|
||||
name=name,
|
||||
color=color or "#6366f1",
|
||||
description=description,
|
||||
provider=CalendarProvider.SYNOLOGY,
|
||||
is_writable=True # 시놀로지 캘린더는 일반적으로 쓰기 가능
|
||||
)
|
||||
|
||||
calendar_list.append(calendar_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"캘린더 정보 조회 실패: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"시놀로지 캘린더 {len(calendar_list)}개 조회 완료")
|
||||
return calendar_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"캘린더 목록 조회 실패: {e}")
|
||||
raise CalendarServiceError(f"캘린더 목록 조회 실패: {str(e)}")
|
||||
|
||||
async def create_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent:
|
||||
"""이벤트 생성"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
try:
|
||||
# 캘린더 객체 가져오기
|
||||
calendar = self.caldav_client.calendar(url=calendar_id)
|
||||
|
||||
# ICS 형식으로 이벤트 생성
|
||||
ics_content = self._event_to_ics(event)
|
||||
|
||||
# 이벤트 추가
|
||||
caldav_event = calendar.add_event(ics_content)
|
||||
|
||||
# 생성된 이벤트 ID 설정
|
||||
event.id = caldav_event.url
|
||||
|
||||
logger.info(f"시놀로지 캘린더 이벤트 생성 완료: {event.title}")
|
||||
return event
|
||||
|
||||
except NotFoundError:
|
||||
raise CalendarNotFoundError(f"캘린더를 찾을 수 없습니다: {calendar_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 생성 실패: {e}")
|
||||
raise CalendarServiceError(f"이벤트 생성 실패: {str(e)}")
|
||||
|
||||
async def update_event(self, calendar_id: str, event: CalendarEvent) -> CalendarEvent:
|
||||
"""이벤트 수정"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
try:
|
||||
# 기존 이벤트 가져오기
|
||||
calendar = self.caldav_client.calendar(url=calendar_id)
|
||||
caldav_event = calendar.event_by_url(event.id)
|
||||
|
||||
# ICS 형식으로 업데이트
|
||||
ics_content = self._event_to_ics(event)
|
||||
caldav_event.data = ics_content
|
||||
caldav_event.save()
|
||||
|
||||
logger.info(f"시놀로지 캘린더 이벤트 수정 완료: {event.title}")
|
||||
return event
|
||||
|
||||
except NotFoundError:
|
||||
raise EventNotFoundError(f"이벤트를 찾을 수 없습니다: {event.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 수정 실패: {e}")
|
||||
raise CalendarServiceError(f"이벤트 수정 실패: {str(e)}")
|
||||
|
||||
async def delete_event(self, calendar_id: str, event_id: str) -> bool:
|
||||
"""이벤트 삭제"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
try:
|
||||
calendar = self.caldav_client.calendar(url=calendar_id)
|
||||
caldav_event = calendar.event_by_url(event_id)
|
||||
caldav_event.delete()
|
||||
|
||||
logger.info(f"시놀로지 캘린더 이벤트 삭제 완료: {event_id}")
|
||||
return True
|
||||
|
||||
except NotFoundError:
|
||||
raise EventNotFoundError(f"이벤트를 찾을 수 없습니다: {event_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 삭제 실패: {e}")
|
||||
raise CalendarServiceError(f"이벤트 삭제 실패: {str(e)}")
|
||||
|
||||
async def get_event(self, calendar_id: str, event_id: str) -> CalendarEvent:
|
||||
"""이벤트 조회"""
|
||||
self._ensure_authenticated()
|
||||
|
||||
try:
|
||||
calendar = self.caldav_client.calendar(url=calendar_id)
|
||||
caldav_event = calendar.event_by_url(event_id)
|
||||
|
||||
# ICS에서 CalendarEvent로 변환
|
||||
event = self._ics_to_event(caldav_event.data)
|
||||
event.id = event_id
|
||||
|
||||
return event
|
||||
|
||||
except NotFoundError:
|
||||
raise EventNotFoundError(f"이벤트를 찾을 수 없습니다: {event_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"이벤트 조회 실패: {e}")
|
||||
raise CalendarServiceError(f"이벤트 조회 실패: {str(e)}")
|
||||
|
||||
def _event_to_ics(self, event: CalendarEvent) -> str:
|
||||
"""CalendarEvent를 ICS 형식으로 변환"""
|
||||
|
||||
# 시간 형식 변환
|
||||
start_str = event.start_time.strftime('%Y%m%dT%H%M%S') if event.start_time else ""
|
||||
end_str = event.end_time.strftime('%Y%m%dT%H%M%S') if event.end_time else ""
|
||||
|
||||
# 카테고리 문자열 생성
|
||||
categories_str = ",".join(event.categories) if event.categories else ""
|
||||
|
||||
# 알림 설정
|
||||
alarm_str = ""
|
||||
if event.reminder_minutes:
|
||||
alarm_str = f"""BEGIN:VALARM
|
||||
TRIGGER:-PT{event.reminder_minutes}M
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Reminder
|
||||
END:VALARM"""
|
||||
|
||||
ics_content = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Todo-Project//Synology Calendar//EN
|
||||
BEGIN:VEVENT
|
||||
UID:{event.id}
|
||||
DTSTART:{start_str}
|
||||
DTEND:{end_str}
|
||||
SUMMARY:{event.title}
|
||||
DESCRIPTION:{event.description or ''}
|
||||
CATEGORIES:{categories_str}
|
||||
STATUS:{event.status}
|
||||
PRIORITY:5
|
||||
CLASS:PRIVATE
|
||||
{alarm_str}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
return ics_content
|
||||
|
||||
def _ics_to_event(self, ics_content: str) -> CalendarEvent:
|
||||
"""ICS 형식을 CalendarEvent로 변환"""
|
||||
# 간단한 ICS 파싱 (실제로는 icalendar 라이브러리 사용 권장)
|
||||
lines = ics_content.split('\n')
|
||||
|
||||
event = CalendarEvent()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith('SUMMARY:'):
|
||||
event.title = line[8:]
|
||||
elif line.startswith('DESCRIPTION:'):
|
||||
event.description = line[12:]
|
||||
elif line.startswith('DTSTART:'):
|
||||
try:
|
||||
event.start_time = datetime.strptime(line[8:], '%Y%m%dT%H%M%S')
|
||||
except ValueError:
|
||||
pass
|
||||
elif line.startswith('DTEND:'):
|
||||
try:
|
||||
event.end_time = datetime.strptime(line[6:], '%Y%m%dT%H%M%S')
|
||||
except ValueError:
|
||||
pass
|
||||
elif line.startswith('CATEGORIES:'):
|
||||
categories = line[11:].split(',')
|
||||
event.categories = [cat.strip() for cat in categories if cat.strip()]
|
||||
elif line.startswith('STATUS:'):
|
||||
event.status = line[7:]
|
||||
|
||||
return event
|
||||
|
||||
async def get_calendar_by_name(self, name: str) -> Optional[CalendarInfo]:
|
||||
"""이름으로 캘린더 찾기"""
|
||||
calendars = await self.get_calendars()
|
||||
for calendar in calendars:
|
||||
if calendar.name.lower() == name.lower():
|
||||
return calendar
|
||||
return None
|
||||
|
||||
async def create_todo_calendar_if_not_exists(self) -> CalendarInfo:
|
||||
"""Todo 전용 캘린더가 없으면 생성"""
|
||||
# 기존 Todo 캘린더 찾기
|
||||
todo_calendar = await self.get_calendar_by_name("Todo")
|
||||
|
||||
if todo_calendar:
|
||||
return todo_calendar
|
||||
|
||||
# Todo 캘린더 생성 (시놀로지에서는 웹 인터페이스를 통해 생성해야 함)
|
||||
# 여기서는 기본 캘린더를 사용하거나 사용자에게 안내
|
||||
calendars = await self.get_calendars()
|
||||
if calendars:
|
||||
logger.info("Todo 전용 캘린더가 없어 첫 번째 캘린더를 사용합니다.")
|
||||
return calendars[0]
|
||||
|
||||
raise CalendarServiceError("사용 가능한 캘린더가 없습니다. 시놀로지에서 캘린더를 먼저 생성해주세요.")
|
||||
|
||||
|
||||
class SynologyMailService:
|
||||
"""시놀로지 MailPlus 서비스 (캘린더 연동용)"""
|
||||
|
||||
def __init__(self, smtp_server: str, smtp_port: int, username: str, password: str):
|
||||
self.smtp_server = smtp_server
|
||||
self.smtp_port = smtp_port
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
async def send_calendar_invitation(self, to_email: str, event: CalendarEvent, ics_content: str):
|
||||
"""캘린더 초대 메일 발송"""
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = self.username
|
||||
msg['To'] = to_email
|
||||
msg['Subject'] = f"📋 할일 일정: {event.title}"
|
||||
|
||||
# 메일 본문
|
||||
body = f"""
|
||||
새로운 할일이 일정에 추가되었습니다.
|
||||
|
||||
제목: {event.title}
|
||||
시작: {event.start_time.strftime('%Y-%m-%d %H:%M') if event.start_time else '미정'}
|
||||
종료: {event.end_time.strftime('%Y-%m-%d %H:%M') if event.end_time else '미정'}
|
||||
설명: {event.description or ''}
|
||||
|
||||
이 메일의 첨부파일을 캘린더 앱에서 열면 일정이 자동으로 추가됩니다.
|
||||
|
||||
-- Todo-Project
|
||||
"""
|
||||
|
||||
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||
|
||||
# ICS 파일 첨부
|
||||
if ics_content:
|
||||
part = MIMEBase('text', 'calendar')
|
||||
part.set_payload(ics_content.encode('utf-8'))
|
||||
encoders.encode_base64(part)
|
||||
part.add_header(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="todo_event.ics"'
|
||||
)
|
||||
part.add_header('Content-Type', 'text/calendar; charset=utf-8')
|
||||
msg.attach(part)
|
||||
|
||||
# SMTP 발송
|
||||
server = smtplib.SMTP(self.smtp_server, self.smtp_port)
|
||||
server.starttls()
|
||||
server.login(self.username, self.password)
|
||||
server.send_message(msg)
|
||||
server.quit()
|
||||
|
||||
logger.info(f"캘린더 초대 메일 발송 완료: {to_email}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"메일 발송 실패: {e}")
|
||||
raise CalendarServiceError(f"메일 발송 실패: {str(e)}")
|
||||
@@ -6,7 +6,9 @@ from fastapi import FastAPI, Request, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .core.config import settings
|
||||
from .api.routes import auth, todos, calendar
|
||||
@@ -46,15 +48,35 @@ app.add_middleware(
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
logger.error(f"Validation 오류 - URL: {request.url}")
|
||||
logger.error(f"Validation 오류 상세: {exc.errors()}")
|
||||
|
||||
# JSON 직렬화 가능한 형태로 에러 변환
|
||||
serializable_errors = []
|
||||
for error in exc.errors():
|
||||
serializable_error = {}
|
||||
for key, value in error.items():
|
||||
if isinstance(value, bytes):
|
||||
serializable_error[key] = value.decode('utf-8')
|
||||
else:
|
||||
serializable_error[key] = str(value) if not isinstance(value, (str, int, float, bool, list, dict, type(None))) else value
|
||||
serializable_errors.append(serializable_error)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"detail": "요청 데이터 검증 실패",
|
||||
"errors": exc.errors()
|
||||
"errors": serializable_errors
|
||||
}
|
||||
)
|
||||
|
||||
# 정적 파일 서빙 (업로드된 이미지)
|
||||
UPLOAD_DIR = "/app/uploads"
|
||||
if not os.path.exists(UPLOAD_DIR):
|
||||
os.makedirs(UPLOAD_DIR)
|
||||
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
|
||||
|
||||
# 라우터 등록
|
||||
from .api.routes import setup
|
||||
app.include_router(setup.router, prefix="/api/setup", tags=["setup"])
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(todos.router, prefix="/api", tags=["todos"])
|
||||
app.include_router(calendar.router, prefix="/api", tags=["calendar"])
|
||||
@@ -87,6 +109,47 @@ async def create_sample_data():
|
||||
return
|
||||
|
||||
|
||||
async def wait_for_database():
|
||||
"""데이터베이스 연결 대기"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# DATABASE_URL 파싱
|
||||
parsed_url = urlparse(settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://"))
|
||||
|
||||
max_retries = 30 # 최대 30번 시도 (30초)
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < max_retries:
|
||||
try:
|
||||
logger.info(f"🔄 데이터베이스 연결 시도 {retry_count + 1}/{max_retries}")
|
||||
|
||||
# asyncpg로 직접 연결 테스트
|
||||
conn = await asyncpg.connect(
|
||||
host=parsed_url.hostname,
|
||||
port=parsed_url.port or 5432,
|
||||
user=parsed_url.username,
|
||||
password=parsed_url.password,
|
||||
database=parsed_url.path.lstrip('/'),
|
||||
timeout=5
|
||||
)
|
||||
await conn.close()
|
||||
|
||||
logger.info("✅ 데이터베이스 연결 성공!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
retry_count += 1
|
||||
logger.warning(f"❌ 데이터베이스 연결 실패 ({retry_count}/{max_retries}): {e}")
|
||||
|
||||
if retry_count < max_retries:
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
logger.error("💥 데이터베이스 연결 최대 재시도 횟수 초과")
|
||||
raise Exception("데이터베이스에 연결할 수 없습니다.")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""애플리케이션 시작 시 초기화"""
|
||||
@@ -94,6 +157,9 @@ async def startup_event():
|
||||
logger.info(f"📊 환경: {settings.ENVIRONMENT}")
|
||||
logger.info(f"🔗 데이터베이스: {settings.DATABASE_URL}")
|
||||
|
||||
# 데이터베이스 연결 대기
|
||||
await wait_for_database()
|
||||
|
||||
# 데이터베이스 초기화
|
||||
from .core.database import init_db
|
||||
await init_db()
|
||||
|
||||
@@ -13,9 +13,9 @@ from ..core.database import Base
|
||||
|
||||
class TodoCategory(str, enum.Enum):
|
||||
"""Todo 카테고리"""
|
||||
TODO = "todo"
|
||||
CALENDAR = "calendar"
|
||||
CHECKLIST = "checklist"
|
||||
MEMO = "memo" # 수신함의 메모 (upload.html에서 생성)
|
||||
TODO = "todo" # Todo 목록 (inbox.html에서 변환된 것)
|
||||
BOARD = "board" # 보드 (프로젝트 관리)
|
||||
|
||||
|
||||
class TodoStatus(str, enum.Enum):
|
||||
@@ -35,20 +35,23 @@ class Todo(Base):
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# 기본 정보
|
||||
title = Column(String(200), nullable=False) # 할일 제목
|
||||
description = Column(Text, nullable=True) # 할일 설명
|
||||
category = Column(Enum(TodoCategory), nullable=True, default=None)
|
||||
title = Column(String(200), nullable=True) # 제목 (메모의 경우 선택사항)
|
||||
description = Column(Text, nullable=False) # 내용 (메모/Todo 모두 필수)
|
||||
category = Column(Enum(TodoCategory), nullable=False, default=TodoCategory.MEMO)
|
||||
status = Column(Enum(TodoStatus), nullable=False, default=TodoStatus.PENDING)
|
||||
|
||||
# 시간 관리
|
||||
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)
|
||||
due_date = Column(DateTime(timezone=True), nullable=True) # 마감일
|
||||
start_date = Column(DateTime(timezone=True), nullable=True) # Todo 시작일 (category=todo일 때만 사용)
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 추가 정보
|
||||
image_urls = Column(Text, nullable=True) # 첨부 이미지 URLs (JSON 배열 형태, 최대 5개)
|
||||
tags = Column(String(500), nullable=True) # 태그 (쉼표로 구분)
|
||||
|
||||
# 보드 관련 (category=board일 때 사용)
|
||||
board_id = Column(UUID(as_uuid=True), nullable=True) # 보드 ID (첫 번째 메모가 보드 생성, 나머지는 하위 메모)
|
||||
is_board_header = Column(Boolean, default=False) # 보드의 헤더(제목) 여부
|
||||
|
||||
# 관계
|
||||
user = relationship("User", back_populates="todos")
|
||||
|
||||
@@ -9,9 +9,9 @@ from enum import Enum
|
||||
|
||||
|
||||
class TodoCategoryEnum(str, Enum):
|
||||
TODO = "todo"
|
||||
CALENDAR = "calendar"
|
||||
CHECKLIST = "checklist"
|
||||
MEMO = "memo" # 수신함의 메모
|
||||
TODO = "todo" # Todo 목록
|
||||
BOARD = "board" # 보드 (프로젝트 관리)
|
||||
|
||||
|
||||
class TodoStatusEnum(str, Enum):
|
||||
@@ -23,59 +23,48 @@ class TodoStatusEnum(str, Enum):
|
||||
|
||||
|
||||
class TodoBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
category: Optional[TodoCategoryEnum] = None
|
||||
title: Optional[str] = Field(None, max_length=200) # 메모의 경우 선택사항
|
||||
description: str = Field(..., min_length=1, max_length=2000) # 내용은 필수
|
||||
category: TodoCategoryEnum = Field(default=TodoCategoryEnum.MEMO)
|
||||
|
||||
|
||||
class TodoCreate(TodoBase):
|
||||
"""Todo 생성"""
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
"""Todo/메모 생성"""
|
||||
start_date: Optional[str] = None # Todo 시작일 (category=todo일 때만 사용)
|
||||
image_urls: Optional[List[str]] = Field(None, max_items=5, description="최대 5개의 이미지 URL")
|
||||
tags: Optional[str] = Field(None, max_length=500)
|
||||
board_id: Optional[str] = None # 보드 ID (보드 하위 메모일 때)
|
||||
is_board_header: Optional[bool] = False # 보드 헤더 여부
|
||||
|
||||
|
||||
class TodoUpdate(BaseModel):
|
||||
"""Todo 수정"""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=2000)
|
||||
"""Todo/메모 수정"""
|
||||
title: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = Field(None, min_length=1, max_length=2000)
|
||||
category: Optional[TodoCategoryEnum] = None
|
||||
status: Optional[TodoStatusEnum] = None
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
start_date: Optional[str] = None # Todo 시작일
|
||||
image_urls: Optional[List[str]] = Field(None, max_items=5, description="최대 5개의 이미지 URL")
|
||||
tags: Optional[str] = Field(None, max_length=500)
|
||||
board_id: Optional[str] = None # 보드 ID
|
||||
is_board_header: Optional[bool] = None # 보드 헤더 여부
|
||||
|
||||
|
||||
class TodoResponse(BaseModel):
|
||||
id: UUID
|
||||
user_id: UUID
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
category: Optional[TodoCategoryEnum] = None
|
||||
title: Optional[str] = None
|
||||
description: str
|
||||
category: TodoCategoryEnum
|
||||
status: TodoStatusEnum
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
|
||||
start_date: Optional[str] = None # Todo 시작일
|
||||
completed_at: Optional[datetime] = None
|
||||
image_urls: Optional[List[str]] = None
|
||||
tags: Optional[str] = None
|
||||
board_id: Optional[str] = None # 보드 ID
|
||||
is_board_header: Optional[bool] = None # 보드 헤더 여부
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TodoStats(BaseModel):
|
||||
"""Todo 통계"""
|
||||
total_count: int
|
||||
pending_count: int
|
||||
in_progress_count: int
|
||||
completed_count: int
|
||||
completion_rate: float # 완료율 (%)
|
||||
|
||||
|
||||
class TodoDashboard(BaseModel):
|
||||
"""Todo 대시보드"""
|
||||
stats: TodoStats
|
||||
today_todos: List[TodoResponse]
|
||||
overdue_todos: List[TodoResponse]
|
||||
upcoming_todos: List[TodoResponse]
|
||||
# 간단한 워크플로우에 맞게 통계 기능 제거
|
||||
|
||||
@@ -2,11 +2,4 @@
|
||||
서비스 레이어 모듈
|
||||
"""
|
||||
|
||||
from .todo_service import TodoService
|
||||
from .calendar_sync_service import CalendarSyncService, get_calendar_sync_service
|
||||
|
||||
__all__ = [
|
||||
"TodoService",
|
||||
"CalendarSyncService",
|
||||
"get_calendar_sync_service"
|
||||
]
|
||||
__all__ = []
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
캘린더 동기화 서비스
|
||||
- 서비스 클래스 기준: 최대 350줄
|
||||
- 간결함 원칙: Todo ↔ 캘린더 동기화만 담당
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
from ..models.todo import TodoItem
|
||||
from ..integrations.calendar import get_calendar_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalendarSyncService:
|
||||
"""Todo와 캘린더 간 동기화 서비스"""
|
||||
|
||||
def __init__(self):
|
||||
self.calendar_router = get_calendar_router()
|
||||
|
||||
async def sync_todo_create(self, todo_item: TodoItem) -> Dict[str, Any]:
|
||||
"""새 할일을 캘린더에 생성"""
|
||||
try:
|
||||
result = await self.calendar_router.sync_todo_to_calendars(todo_item)
|
||||
logger.info(f"할일 {todo_item.id} 캘린더 생성 완료")
|
||||
return {"success": True, "result": result}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"할일 {todo_item.id} 캘린더 생성 실패: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def sync_todo_complete(self, todo_item: TodoItem) -> Dict[str, Any]:
|
||||
"""완료된 할일의 캘린더 태그 업데이트 (todo → 완료)"""
|
||||
try:
|
||||
# 향후 구현: 기존 이벤트를 찾아서 태그 업데이트
|
||||
logger.info(f"할일 {todo_item.id} 완료 상태로 캘린더 업데이트")
|
||||
return {"success": True, "action": "completed"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"할일 {todo_item.id} 완료 상태 캘린더 업데이트 실패: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def sync_todo_delay(self, todo_item: TodoItem) -> Dict[str, Any]:
|
||||
"""지연된 할일의 캘린더 날짜 수정"""
|
||||
try:
|
||||
# 향후 구현: 기존 이벤트를 찾아서 날짜 수정
|
||||
logger.info(f"할일 {todo_item.id} 지연 날짜로 캘린더 업데이트")
|
||||
return {"success": True, "action": "delayed"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"할일 {todo_item.id} 지연 날짜 캘린더 업데이트 실패: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def sync_todo_delete(self, todo_item: TodoItem) -> Dict[str, Any]:
|
||||
"""삭제된 할일의 캘린더 이벤트 제거"""
|
||||
try:
|
||||
# 향후 구현: 기존 이벤트를 찾아서 삭제
|
||||
logger.info(f"할일 {todo_item.id} 캘린더 이벤트 삭제")
|
||||
return {"success": True, "action": "deleted"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"할일 {todo_item.id} 캘린더 이벤트 삭제 실패: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
# 전역 인스턴스
|
||||
_calendar_sync_service: Optional[CalendarSyncService] = None
|
||||
|
||||
|
||||
def get_calendar_sync_service() -> CalendarSyncService:
|
||||
"""캘린더 동기화 서비스 싱글톤 인스턴스"""
|
||||
global _calendar_sync_service
|
||||
if _calendar_sync_service is None:
|
||||
_calendar_sync_service = CalendarSyncService()
|
||||
return _calendar_sync_service
|
||||
@@ -13,6 +13,47 @@ def ensure_upload_dir():
|
||||
if not os.path.exists(UPLOAD_DIR):
|
||||
os.makedirs(UPLOAD_DIR)
|
||||
|
||||
def save_image(image_data: bytes, filename: str = None) -> Optional[str]:
|
||||
"""이미지 데이터를 파일로 저장하고 경로 반환"""
|
||||
try:
|
||||
ensure_upload_dir()
|
||||
|
||||
# 파일명 생성
|
||||
if not filename:
|
||||
filename = f"{uuid.uuid4()}.jpg"
|
||||
else:
|
||||
# 확장자 확인 및 변경
|
||||
name, ext = os.path.splitext(filename)
|
||||
filename = f"{name}_{uuid.uuid4()}.jpg"
|
||||
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
|
||||
# 이미지 처리 및 저장
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# RGBA를 RGB로 변환 (JPEG는 투명도 지원 안함)
|
||||
if image.mode in ('RGBA', 'LA'):
|
||||
background = Image.new('RGB', image.size, (255, 255, 255))
|
||||
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
|
||||
image = background
|
||||
elif image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# 이미지 크기 조정 (최대 1920px)
|
||||
max_size = 1920
|
||||
if image.width > max_size or image.height > max_size:
|
||||
image.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
|
||||
|
||||
# JPEG로 저장
|
||||
image.save(filepath, 'JPEG', quality=85, optimize=True)
|
||||
|
||||
# 웹 경로 반환
|
||||
return f"/uploads/{filename}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"이미지 저장 실패: {e}")
|
||||
return None
|
||||
|
||||
def save_base64_image(base64_string: str) -> Optional[str]:
|
||||
"""Base64 이미지를 파일로 저장하고 경로 반환"""
|
||||
try:
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
"""
|
||||
Todo 비즈니스 로직 서비스
|
||||
- 서비스 클래스 기준: 최대 350줄
|
||||
- 간결함 원칙: 핵심 Todo 로직만 포함
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
import logging
|
||||
|
||||
from ..models.user import User
|
||||
from ..models.todo import TodoItem, TodoComment
|
||||
from ..schemas.todo import (
|
||||
TodoItemCreate, TodoItemSchedule, TodoItemSplit, TodoItemDelay,
|
||||
TodoItemResponse, TodoCommentCreate, TodoCommentResponse
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TodoService:
|
||||
"""Todo 관련 비즈니스 로직 서비스"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_todo(self, todo_data: TodoItemCreate, user_id: UUID) -> TodoItemResponse:
|
||||
"""새 할일 생성"""
|
||||
new_todo = TodoItem(
|
||||
user_id=user_id,
|
||||
content=todo_data.content,
|
||||
status="draft"
|
||||
)
|
||||
|
||||
self.db.add(new_todo)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(new_todo)
|
||||
|
||||
return await self._build_todo_response(new_todo)
|
||||
|
||||
async def schedule_todo(
|
||||
self,
|
||||
todo_id: UUID,
|
||||
schedule_data: TodoItemSchedule,
|
||||
user_id: UUID
|
||||
) -> TodoItemResponse:
|
||||
"""할일 일정 설정"""
|
||||
todo_item = await self._get_user_todo(todo_id, user_id, "draft")
|
||||
|
||||
# 2시간 이상인 경우 분할 제안
|
||||
if schedule_data.estimated_minutes > 120:
|
||||
raise ValueError("Tasks longer than 2 hours should be split")
|
||||
|
||||
todo_item.start_date = schedule_data.start_date
|
||||
todo_item.estimated_minutes = schedule_data.estimated_minutes
|
||||
todo_item.status = "scheduled"
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(todo_item)
|
||||
|
||||
return await self._build_todo_response(todo_item)
|
||||
|
||||
async def complete_todo(self, todo_id: UUID, user_id: UUID) -> TodoItemResponse:
|
||||
"""할일 완료"""
|
||||
todo_item = await self._get_user_todo(todo_id, user_id, "active")
|
||||
|
||||
todo_item.status = "completed"
|
||||
todo_item.completed_at = datetime.utcnow()
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(todo_item)
|
||||
|
||||
return await self._build_todo_response(todo_item)
|
||||
|
||||
async def delay_todo(
|
||||
self,
|
||||
todo_id: UUID,
|
||||
delay_data: TodoItemDelay,
|
||||
user_id: UUID
|
||||
) -> TodoItemResponse:
|
||||
"""할일 지연"""
|
||||
todo_item = await self._get_user_todo(todo_id, user_id, "active")
|
||||
|
||||
todo_item.status = "delayed"
|
||||
todo_item.delayed_until = delay_data.delayed_until
|
||||
todo_item.start_date = delay_data.delayed_until
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(todo_item)
|
||||
|
||||
return await self._build_todo_response(todo_item)
|
||||
|
||||
async def split_todo(
|
||||
self,
|
||||
todo_id: UUID,
|
||||
split_data: TodoItemSplit,
|
||||
user_id: UUID
|
||||
) -> List[TodoItemResponse]:
|
||||
"""할일 분할"""
|
||||
original_todo = await self._get_user_todo(todo_id, user_id, "draft")
|
||||
|
||||
# 분할된 할일들 생성
|
||||
subtasks = []
|
||||
for i, (content, minutes) in enumerate(zip(split_data.subtasks, split_data.estimated_minutes_per_task)):
|
||||
if minutes > 120:
|
||||
raise ValueError(f"Subtask {i+1} is longer than 2 hours")
|
||||
|
||||
subtask = TodoItem(
|
||||
user_id=user_id,
|
||||
content=content,
|
||||
status="draft",
|
||||
parent_id=original_todo.id,
|
||||
split_order=i + 1
|
||||
)
|
||||
self.db.add(subtask)
|
||||
subtasks.append(subtask)
|
||||
|
||||
original_todo.status = "split"
|
||||
await self.db.commit()
|
||||
|
||||
# 응답 데이터 구성
|
||||
response_data = []
|
||||
for subtask in subtasks:
|
||||
await self.db.refresh(subtask)
|
||||
response_data.append(await self._build_todo_response(subtask))
|
||||
|
||||
return response_data
|
||||
|
||||
async def get_todos(
|
||||
self,
|
||||
user_id: UUID,
|
||||
status_filter: Optional[str] = None
|
||||
) -> List[TodoItemResponse]:
|
||||
"""할일 목록 조회"""
|
||||
query = select(TodoItem).where(TodoItem.user_id == user_id)
|
||||
|
||||
if status_filter:
|
||||
query = query.where(TodoItem.status == status_filter)
|
||||
|
||||
query = query.order_by(TodoItem.created_at.desc())
|
||||
|
||||
result = await self.db.execute(query)
|
||||
todo_items = result.scalars().all()
|
||||
|
||||
response_data = []
|
||||
for todo_item in todo_items:
|
||||
response_data.append(await self._build_todo_response(todo_item))
|
||||
|
||||
return response_data
|
||||
|
||||
async def get_active_todos(self, user_id: UUID) -> List[TodoItemResponse]:
|
||||
"""활성 할일 조회 (scheduled → active 자동 변환 포함)"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
# scheduled → active 자동 변환
|
||||
update_result = await self.db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.user_id == 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 self.db.commit()
|
||||
|
||||
# active 할일들 조회
|
||||
result = await self.db.execute(
|
||||
select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.user_id == user_id,
|
||||
TodoItem.status == "active"
|
||||
)
|
||||
).order_by(TodoItem.start_date.asc())
|
||||
)
|
||||
active_todos = result.scalars().all()
|
||||
|
||||
response_data = []
|
||||
for todo_item in active_todos:
|
||||
response_data.append(await self._build_todo_response(todo_item))
|
||||
|
||||
return response_data
|
||||
|
||||
async def create_comment(
|
||||
self,
|
||||
todo_id: UUID,
|
||||
comment_data: TodoCommentCreate,
|
||||
user_id: UUID
|
||||
) -> TodoCommentResponse:
|
||||
"""댓글 생성"""
|
||||
# 할일 존재 확인
|
||||
await self._get_user_todo(todo_id, user_id)
|
||||
|
||||
new_comment = TodoComment(
|
||||
todo_item_id=todo_id,
|
||||
user_id=user_id,
|
||||
content=comment_data.content
|
||||
)
|
||||
|
||||
self.db.add(new_comment)
|
||||
await self.db.commit()
|
||||
await self.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
|
||||
)
|
||||
|
||||
async def get_comments(self, todo_id: UUID, user_id: UUID) -> List[TodoCommentResponse]:
|
||||
"""댓글 목록 조회"""
|
||||
# 할일 존재 확인
|
||||
await self._get_user_todo(todo_id, user_id)
|
||||
|
||||
result = await self.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
|
||||
]
|
||||
|
||||
# ========================================================================
|
||||
# 헬퍼 메서드들
|
||||
# ========================================================================
|
||||
|
||||
async def _get_user_todo(
|
||||
self,
|
||||
todo_id: UUID,
|
||||
user_id: UUID,
|
||||
required_status: Optional[str] = None
|
||||
) -> TodoItem:
|
||||
"""사용자의 할일 조회"""
|
||||
query = select(TodoItem).where(
|
||||
and_(
|
||||
TodoItem.id == todo_id,
|
||||
TodoItem.user_id == user_id
|
||||
)
|
||||
)
|
||||
|
||||
if required_status:
|
||||
query = query.where(TodoItem.status == required_status)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
todo_item = result.scalar_one_or_none()
|
||||
|
||||
if not todo_item:
|
||||
detail = "Todo item not found"
|
||||
if required_status:
|
||||
detail += f" or not in {required_status} status"
|
||||
raise ValueError(detail)
|
||||
|
||||
return todo_item
|
||||
|
||||
async def _get_comment_count(self, todo_id: UUID) -> int:
|
||||
"""댓글 수 조회"""
|
||||
result = await self.db.execute(
|
||||
select(func.count(TodoComment.id)).where(TodoComment.todo_item_id == todo_id)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def _build_todo_response(self, todo_item: TodoItem) -> TodoItemResponse:
|
||||
"""TodoItem을 TodoItemResponse로 변환"""
|
||||
comment_count = await self._get_comment_count(todo_item.id)
|
||||
|
||||
return 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
|
||||
)
|
||||
179
deploy-synology.sh
Executable file
179
deploy-synology.sh
Executable file
@@ -0,0 +1,179 @@
|
||||
#!/bin/bash
|
||||
|
||||
# =============================================================================
|
||||
# Todo-Project 시놀로지 배포 스크립트
|
||||
# =============================================================================
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
# 시놀로지 환경 확인
|
||||
check_synology_environment() {
|
||||
log_info "시놀로지 환경 확인 중..."
|
||||
|
||||
# Docker 설치 확인
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker가 설치되지 않았습니다. Container Manager 또는 Docker 패키지를 설치하세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Docker Compose 확인
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
log_error "Docker Compose가 설치되지 않았습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "Docker 환경 확인 완료"
|
||||
}
|
||||
|
||||
# 디렉토리 생성
|
||||
create_directories() {
|
||||
log_info "시놀로지 디렉토리 구조 생성 중..."
|
||||
|
||||
# 이미지 업로드 저장소 (volume1)
|
||||
sudo mkdir -p /volume1/todo-project/uploads
|
||||
sudo chmod 755 /volume1/todo-project/uploads
|
||||
sudo chown -R 1000:1000 /volume1/todo-project
|
||||
|
||||
# 설정 및 데이터베이스 저장소 (volume3)
|
||||
sudo mkdir -p /volume3/docker/todo-project/config/migrations
|
||||
sudo mkdir -p /volume3/docker/todo-project/postgres
|
||||
sudo chown -R 999:999 /volume3/docker/todo-project/postgres
|
||||
sudo chown -R 1000:1000 /volume3/docker/todo-project/config
|
||||
|
||||
log_success "디렉토리 구조 생성 완료"
|
||||
}
|
||||
|
||||
# 환경 파일 설정
|
||||
setup_environment() {
|
||||
log_info "환경 파일 설정 중..."
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
if [ -f env.synology.example ]; then
|
||||
cp env.synology.example .env
|
||||
log_warning ".env 파일이 생성되었습니다. 필수 설정을 수정하세요:"
|
||||
log_warning "- SECRET_KEY"
|
||||
log_warning "- POSTGRES_PASSWORD"
|
||||
log_warning "- CORS_ORIGINS (시놀로지 IP 추가)"
|
||||
echo
|
||||
read -p "환경 설정을 완료했습니까? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_error "환경 설정을 완료한 후 다시 실행하세요."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_error "env.synology.example 파일이 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_success "기존 .env 파일을 사용합니다."
|
||||
fi
|
||||
}
|
||||
|
||||
# 마이그레이션 파일 복사
|
||||
copy_migrations() {
|
||||
log_info "데이터베이스 마이그레이션 파일 복사 중..."
|
||||
|
||||
if [ -d backend/migrations ]; then
|
||||
sudo cp -r backend/migrations/* /volume3/docker/todo-project/config/migrations/
|
||||
log_success "마이그레이션 파일 복사 완료"
|
||||
else
|
||||
log_warning "마이그레이션 파일이 없습니다."
|
||||
fi
|
||||
}
|
||||
|
||||
# Docker 이미지 빌드 및 배포
|
||||
deploy_application() {
|
||||
log_info "Todo-Project 배포 중..."
|
||||
|
||||
# 기존 컨테이너 중지 및 제거
|
||||
if docker-compose ps -q | grep -q .; then
|
||||
log_info "기존 컨테이너 중지 중..."
|
||||
docker-compose down
|
||||
fi
|
||||
|
||||
# 이미지 빌드 및 컨테이너 시작
|
||||
log_info "Docker 이미지 빌드 및 컨테이너 시작 중..."
|
||||
docker-compose up -d --build
|
||||
|
||||
# 컨테이너 시작 대기
|
||||
log_info "컨테이너 시작 대기 중..."
|
||||
sleep 30
|
||||
|
||||
# 헬스체크
|
||||
log_info "서비스 상태 확인 중..."
|
||||
if curl -f http://localhost:9000/health &> /dev/null; then
|
||||
log_success "백엔드 서비스 정상 작동"
|
||||
else
|
||||
log_error "백엔드 서비스 오류"
|
||||
docker-compose logs backend
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 배포 완료 안내
|
||||
show_completion_info() {
|
||||
log_success "🎉 Todo-Project 시놀로지 배포 완료!"
|
||||
echo
|
||||
echo "=== 접속 정보 ==="
|
||||
echo "웹 인터페이스: http://$(hostname -I | awk '{print $1}'):${FRONTEND_PORT:-4000}"
|
||||
echo "API 서버: http://$(hostname -I | awk '{print $1}'):${BACKEND_PORT:-9000}"
|
||||
echo
|
||||
echo "=== 다음 단계 ==="
|
||||
echo "1. 웹 브라우저에서 접속하여 관리자 계정을 생성하세요"
|
||||
echo "2. 리버스 프록시 설정 (선택사항)"
|
||||
echo "3. 방화벽 설정 확인"
|
||||
echo
|
||||
echo "=== 유용한 명령어 ==="
|
||||
echo "로그 확인: docker-compose logs -f"
|
||||
echo "컨테이너 상태: docker-compose ps"
|
||||
echo "서비스 재시작: docker-compose restart"
|
||||
echo "완전 재배포: docker-compose down && docker-compose up -d --build"
|
||||
}
|
||||
|
||||
# 메인 실행 함수
|
||||
main() {
|
||||
echo "=== Todo-Project 시놀로지 배포 스크립트 ==="
|
||||
echo
|
||||
|
||||
# 루트 권한 확인
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
log_error "이 스크립트는 root 권한이 필요합니다. sudo로 실행하세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 단계별 실행
|
||||
check_synology_environment
|
||||
create_directories
|
||||
setup_environment
|
||||
copy_migrations
|
||||
deploy_application
|
||||
show_completion_info
|
||||
}
|
||||
|
||||
# 스크립트 실행
|
||||
main "$@"
|
||||
@@ -1,56 +1,84 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "4000:80"
|
||||
- "${FRONTEND_PORT:-4000}:80"
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
- API_BASE_URL=http://localhost:9000/api
|
||||
volumes:
|
||||
- ./frontend/static:/usr/share/nginx/html/static
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- todo-network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "${BACKEND_PORT:-9000}:9000"
|
||||
depends_on:
|
||||
- database
|
||||
database:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://todo_user:${POSTGRES_PASSWORD:-todo_password}@database:5432/todo_db
|
||||
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this-in-production}
|
||||
- DEBUG=${DEBUG:-true}
|
||||
- CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"]
|
||||
- SYNOLOGY_DSM_URL=${SYNOLOGY_DSM_URL:-}
|
||||
- SYNOLOGY_USERNAME=${SYNOLOGY_USERNAME:-}
|
||||
- SYNOLOGY_PASSWORD=${SYNOLOGY_PASSWORD:-}
|
||||
- ENABLE_SYNOLOGY_INTEGRATION=${ENABLE_SYNOLOGY_INTEGRATION:-false}
|
||||
- DEBUG=${DEBUG:-false}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-["http://localhost:4000", "http://127.0.0.1:4000"]}
|
||||
# Synology MailPlus 통합 설정 (선택사항)
|
||||
- SYNOLOGY_MAIL_SERVER=${SYNOLOGY_MAIL_SERVER:-}
|
||||
- SYNOLOGY_MAIL_USERNAME=${SYNOLOGY_MAIL_USERNAME:-}
|
||||
- SYNOLOGY_MAIL_PASSWORD=${SYNOLOGY_MAIL_PASSWORD:-}
|
||||
- ENABLE_MAIL_MONITORING=${ENABLE_MAIL_MONITORING:-false}
|
||||
- MAIL_CHECK_INTERVAL=${MAIL_CHECK_INTERVAL:-300}
|
||||
- TODO_KEYWORDS=${TODO_KEYWORDS:-todo,할일,task}
|
||||
- ATTACHMENTS_PATH=/data/uploads
|
||||
volumes:
|
||||
# 시놀로지 볼륨 매핑
|
||||
- ${SYNOLOGY_UPLOADS_PATH:-/volume1/todo-project/uploads}:/data/uploads
|
||||
- ${SYNOLOGY_CONFIG_PATH:-/volume3/docker/todo-project/config}:/app/config
|
||||
# 로컬 개발용 (시놀로지에서는 제거)
|
||||
- ./backend/src:/app/src
|
||||
- ./backend/uploads:/app/uploads
|
||||
- todo_uploads:/data/uploads
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- todo-network
|
||||
|
||||
database:
|
||||
image: postgres:15-alpine
|
||||
ports:
|
||||
- "5434:5432"
|
||||
- "${DATABASE_PORT:-5432}:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-todo_user}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-todo_password}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-todo_db}
|
||||
volumes:
|
||||
# 시놀로지 볼륨 매핑
|
||||
- ${SYNOLOGY_DB_PATH:-/volume3/docker/todo-project/postgres}:/var/lib/postgresql/data
|
||||
- ${SYNOLOGY_CONFIG_PATH:-/volume3/docker/todo-project/config}/migrations:/docker-entrypoint-initdb.d
|
||||
# 로컬 개발용 (시놀로지에서는 제거)
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/init:/docker-entrypoint-initdb.d
|
||||
- ./backend/migrations:/docker-entrypoint-initdb.d
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-todo_user} -d ${POSTGRES_DB:-todo_db}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- todo-network
|
||||
|
||||
networks:
|
||||
todo-network:
|
||||
driver: bridge
|
||||
|
||||
# 로컬 개발용 볼륨 (시놀로지에서는 사용하지 않음)
|
||||
volumes:
|
||||
postgres_data:
|
||||
todo_uploads:
|
||||
todo_uploads:
|
||||
46
env.synology.example
Normal file
46
env.synology.example
Normal file
@@ -0,0 +1,46 @@
|
||||
# =============================================================================
|
||||
# Todo-Project 시놀로지 배포 환경 설정
|
||||
# =============================================================================
|
||||
# 이 파일을 .env로 복사하여 사용하세요: cp env.synology.example .env
|
||||
|
||||
# --- 필수 설정 (반드시 변경하세요!) ---
|
||||
SECRET_KEY=YOUR_VERY_LONG_AND_RANDOM_SECRET_KEY_FOR_SYNOLOGY_PRODUCTION
|
||||
POSTGRES_PASSWORD=YOUR_SECURE_DATABASE_PASSWORD
|
||||
|
||||
# --- 포트 설정 ---
|
||||
FRONTEND_PORT=4000
|
||||
BACKEND_PORT=9000
|
||||
DATABASE_PORT=5432
|
||||
|
||||
# --- 시놀로지 볼륨 경로 설정 ---
|
||||
# 이미지 업로드 저장소 (volume1)
|
||||
SYNOLOGY_UPLOADS_PATH=/volume1/todo-project/uploads
|
||||
|
||||
# 설정 및 데이터베이스 저장소 (volume3)
|
||||
SYNOLOGY_CONFIG_PATH=/volume3/docker/todo-project/config
|
||||
SYNOLOGY_DB_PATH=/volume3/docker/todo-project/postgres
|
||||
|
||||
# --- 애플리케이션 설정 ---
|
||||
DEBUG=false
|
||||
POSTGRES_USER=todo_user
|
||||
POSTGRES_DB=todo_db
|
||||
|
||||
# --- CORS 설정 (시놀로지 IP/도메인에 맞게 수정) ---
|
||||
# 예시: CORS_ORIGINS=["http://192.168.1.100:4000", "https://your-domain.synology.me:4000"]
|
||||
CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"]
|
||||
|
||||
# --- Synology MailPlus 통합 설정 (선택사항) ---
|
||||
SYNOLOGY_MAIL_SERVER=
|
||||
SYNOLOGY_MAIL_USERNAME=
|
||||
SYNOLOGY_MAIL_PASSWORD=
|
||||
ENABLE_MAIL_MONITORING=false
|
||||
MAIL_CHECK_INTERVAL=300
|
||||
TODO_KEYWORDS=todo,할일,task
|
||||
|
||||
# =============================================================================
|
||||
# 시놀로지 배포 전 체크리스트:
|
||||
# 1. SECRET_KEY와 POSTGRES_PASSWORD를 안전한 값으로 변경
|
||||
# 2. CORS_ORIGINS에 시놀로지 IP/도메인 추가
|
||||
# 3. 볼륨 경로가 실제 시놀로지 환경과 일치하는지 확인
|
||||
# 4. 포트가 시놀로지에서 사용 가능한지 확인
|
||||
# =============================================================================
|
||||
@@ -4,8 +4,8 @@ FROM nginx:alpine
|
||||
# 정적 파일들을 nginx 웹 루트로 복사
|
||||
COPY . /usr/share/nginx/html/
|
||||
|
||||
# nginx 설정 파일 복사 (있는 경우)
|
||||
# COPY nginx.conf /etc/nginx/nginx.conf
|
||||
# nginx 설정 파일 복사
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 포트 80 노출
|
||||
EXPOSE 80
|
||||
|
||||
574
frontend/archive.html
Normal file
574
frontend/archive.html
Normal file
@@ -0,0 +1,574 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>아카이브 - Todo Project</title>
|
||||
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--parchment: #f7f3e9;
|
||||
--parchment-dark: #f0ead6;
|
||||
--ink: #2c1810;
|
||||
--ink-light: #5d4e37;
|
||||
--sepia: #8b7355;
|
||||
--gold: #d4af37;
|
||||
--shadow: rgba(139, 115, 85, 0.2);
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
background: linear-gradient(135deg, #f7f3e9 0%, #f0ead6 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.parchment-container {
|
||||
background: var(--parchment);
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, rgba(139, 115, 85, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(139, 115, 85, 0.05) 0%, transparent 50%);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 8px 32px var(--shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.parchment-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, var(--gold), var(--sepia), var(--gold));
|
||||
border-radius: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.header-vintage {
|
||||
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
|
||||
border-bottom: 3px solid var(--gold);
|
||||
box-shadow: 0 2px 10px var(--shadow);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
background: var(--parchment-dark);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: var(--parchment);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
width: 100%;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
color: var(--ink);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.archive-board {
|
||||
background: var(--parchment-dark);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.archive-board:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px var(--shadow);
|
||||
border-color: var(--gold);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.archive-board.expanded {
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 15px rgba(212, 175, 55, 0.3);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-header-archive {
|
||||
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
|
||||
color: var(--parchment);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 15px var(--shadow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-header-archive:hover {
|
||||
background: linear-gradient(135deg, var(--ink-light), var(--ink));
|
||||
}
|
||||
|
||||
.completed-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.memo-item-archive {
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--sepia);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
|
||||
color: var(--parchment);
|
||||
border: 2px solid var(--gold);
|
||||
border-radius: 20px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
background: var(--parchment);
|
||||
border: 1px solid var(--sepia);
|
||||
border-radius: 20px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.filter-button:hover, .filter-button.active {
|
||||
background: var(--gold);
|
||||
border-color: var(--gold);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.date-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--sepia);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(40px, 1fr));
|
||||
gap: 0.25rem;
|
||||
max-width: 160px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sepia);
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: rgba(44, 24, 16, 0.8);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.memos-container {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.memos-container.expanded {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.date-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="header-vintage">
|
||||
<div class="max-w-6xl mx-auto px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-2xl font-semibold" style="color: var(--ink);">
|
||||
<i class="fas fa-archive mr-3" style="color: var(--gold);"></i>
|
||||
아카이브
|
||||
</h1>
|
||||
<span id="archiveCount" class="ml-4 px-3 py-1 bg-gold text-white rounded-full text-sm font-medium"></span>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="upload.html" class="action-button">
|
||||
<i class="fas fa-feather-alt mr-2"></i>메모
|
||||
</a>
|
||||
<a href="inbox.html" class="action-button">
|
||||
<i class="fas fa-inbox mr-2"></i>수신함
|
||||
</a>
|
||||
<a href="todo-list.html" class="action-button">
|
||||
<i class="fas fa-tasks mr-2"></i>Todo 목록
|
||||
</a>
|
||||
<a href="board.html" class="action-button">
|
||||
<i class="fas fa-clipboard mr-2"></i>보드
|
||||
</a>
|
||||
<a href="archive.html" class="action-button" style="background: var(--gold); border-color: var(--gold); color: var(--ink);">
|
||||
<i class="fas fa-archive mr-2"></i>아카이브
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-6xl mx-auto px-4 py-8">
|
||||
<!-- 검색 및 필터 -->
|
||||
<div class="search-container">
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<i class="fas fa-search" style="color: var(--gold);"></i>
|
||||
<input type="text" id="searchInput" class="search-input" placeholder="보드 제목이나 내용으로 검색하세요..." onkeyup="handleSearch()">
|
||||
</div>
|
||||
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-button active" onclick="setDateFilter('all')">전체</button>
|
||||
<button class="filter-button" onclick="setDateFilter('week')">최근 1주일</button>
|
||||
<button class="filter-button" onclick="setDateFilter('month')">최근 1개월</button>
|
||||
<button class="filter-button" onclick="setDateFilter('quarter')">최근 3개월</button>
|
||||
<button class="filter-button" onclick="setDateFilter('year')">최근 1년</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 아카이브 목록 -->
|
||||
<div id="archiveContainer">
|
||||
<!-- 완료된 보드들이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="hidden text-center py-12">
|
||||
<div class="parchment-container p-8">
|
||||
<i class="fas fa-archive text-6xl mb-4" style="color: var(--sepia); opacity: 0.5;"></i>
|
||||
<h3 class="text-xl font-medium mb-2" style="color: var(--ink-light);">완료된 보드가 없습니다</h3>
|
||||
<p class="text-sm mb-6" style="color: var(--sepia);">보드를 완료하면 여기에서 확인할 수 있습니다</p>
|
||||
<a href="board.html" class="action-button">
|
||||
<i class="fas fa-clipboard mr-2"></i>보드로 이동
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script>
|
||||
let allArchivedBoards = [];
|
||||
let filteredBoards = [];
|
||||
let currentDateFilter = 'all';
|
||||
let currentSearchTerm = '';
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 인증 확인
|
||||
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
loadArchivedBoards();
|
||||
});
|
||||
|
||||
// 완료된 보드 목록 로드
|
||||
async function loadArchivedBoards() {
|
||||
try {
|
||||
const boards = await TodoAPI.getTodos(null, 'board');
|
||||
|
||||
// 완료된 보드만 필터링하고 그룹화
|
||||
const completedBoards = boards.filter(item => item.status === 'completed');
|
||||
const boardGroups = {};
|
||||
|
||||
completedBoards.forEach(item => {
|
||||
const boardId = item.board_id || item.id;
|
||||
if (!boardGroups[boardId]) {
|
||||
boardGroups[boardId] = {
|
||||
header: null,
|
||||
memos: []
|
||||
};
|
||||
}
|
||||
|
||||
if (item.is_board_header) {
|
||||
boardGroups[boardId].header = item;
|
||||
} else {
|
||||
boardGroups[boardId].memos.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
allArchivedBoards = Object.values(boardGroups)
|
||||
.filter(board => board.header)
|
||||
.sort((a, b) => new Date(b.header.completed_at) - new Date(a.header.completed_at));
|
||||
|
||||
applyFilters();
|
||||
} catch (error) {
|
||||
console.error('아카이브 로드 실패:', error);
|
||||
showEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
// 필터 적용
|
||||
function applyFilters() {
|
||||
let filtered = [...allArchivedBoards];
|
||||
|
||||
// 날짜 필터 적용
|
||||
if (currentDateFilter !== 'all') {
|
||||
const now = new Date();
|
||||
const filterDate = new Date();
|
||||
|
||||
switch (currentDateFilter) {
|
||||
case 'week':
|
||||
filterDate.setDate(now.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
filterDate.setMonth(now.getMonth() - 1);
|
||||
break;
|
||||
case 'quarter':
|
||||
filterDate.setMonth(now.getMonth() - 3);
|
||||
break;
|
||||
case 'year':
|
||||
filterDate.setFullYear(now.getFullYear() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
filtered = filtered.filter(board =>
|
||||
new Date(board.header.completed_at) >= filterDate
|
||||
);
|
||||
}
|
||||
|
||||
// 검색어 필터 적용
|
||||
if (currentSearchTerm) {
|
||||
const searchLower = currentSearchTerm.toLowerCase();
|
||||
filtered = filtered.filter(board => {
|
||||
const headerMatch = (board.header.title || board.header.description || '').toLowerCase().includes(searchLower);
|
||||
const memoMatch = board.memos.some(memo =>
|
||||
memo.description.toLowerCase().includes(searchLower)
|
||||
);
|
||||
return headerMatch || memoMatch;
|
||||
});
|
||||
}
|
||||
|
||||
filteredBoards = filtered;
|
||||
renderArchivedBoards();
|
||||
}
|
||||
|
||||
// 아카이브 보드 렌더링
|
||||
function renderArchivedBoards() {
|
||||
const archiveContainer = document.getElementById('archiveContainer');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const archiveCount = document.getElementById('archiveCount');
|
||||
|
||||
archiveCount.textContent = `${filteredBoards.length}개`;
|
||||
|
||||
if (!filteredBoards || filteredBoards.length === 0) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
archiveContainer.innerHTML = filteredBoards.map(board => {
|
||||
const header = board.header;
|
||||
const memos = board.memos.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
const completedDate = new Date(header.completed_at);
|
||||
const createdDate = new Date(header.created_at);
|
||||
const duration = Math.ceil((completedDate - createdDate) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return `
|
||||
<div class="archive-board" id="board_${header.id}">
|
||||
<div class="board-header-archive" onclick="toggleBoardExpansion('${header.id}')">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">
|
||||
${header.title || header.description}
|
||||
<span class="completed-badge">
|
||||
<i class="fas fa-check mr-1"></i>완료됨
|
||||
</span>
|
||||
</h3>
|
||||
<i id="expandIcon_${header.id}" class="fas fa-chevron-down transition-transform"></i>
|
||||
</div>
|
||||
<div class="date-info">
|
||||
<span><i class="fas fa-calendar-plus mr-1"></i>시작: ${createdDate.toLocaleDateString('ko-KR')}</span>
|
||||
<span><i class="fas fa-calendar-check mr-1"></i>완료: ${completedDate.toLocaleDateString('ko-KR')}</span>
|
||||
<span><i class="fas fa-clock mr-1"></i>기간: ${duration}일</span>
|
||||
<span><i class="fas fa-sticky-note mr-1"></i>메모: ${memos.length}개</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="memos_${header.id}" class="memos-container">
|
||||
<div class="p-2">
|
||||
${memos.length > 0 ? memos.map(memo => {
|
||||
const memoDate = new Date(memo.created_at);
|
||||
const hasImages = memo.image_urls && memo.image_urls.length > 0;
|
||||
|
||||
return `
|
||||
<div class="memo-item-archive">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<p class="mb-2">${memo.description}</p>
|
||||
<div class="flex items-center text-xs" style="color: var(--sepia);">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span>${memoDate.toLocaleDateString('ko-KR')} ${memoDate.toLocaleTimeString('ko-KR', {hour: '2-digit', minute: '2-digit'})}</span>
|
||||
${hasImages ? `<i class="fas fa-images ml-3 mr-1"></i><span>${memo.image_urls.length}장</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${hasImages ? `
|
||||
<div class="image-grid mt-2">
|
||||
${memo.image_urls.slice(0, 6).map(url => `
|
||||
<div class="image-item">
|
||||
<img src="${url}" alt="첨부 이미지" onclick="showImageModal('${url}')">
|
||||
</div>
|
||||
`).join('')}
|
||||
${memo.image_urls.length > 6 ? `
|
||||
<div class="image-item flex items-center justify-center" style="background: var(--parchment); border: 2px dashed var(--sepia);">
|
||||
<span class="text-xs" style="color: var(--sepia);">+${memo.image_urls.length - 6}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('') : '<p class="text-center text-sm" style="color: var(--sepia);">메모가 없습니다</p>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 빈 상태 표시
|
||||
function showEmptyState() {
|
||||
document.getElementById('archiveContainer').innerHTML = '';
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
document.getElementById('archiveCount').textContent = '0개';
|
||||
}
|
||||
|
||||
// 보드 확장/축소
|
||||
function toggleBoardExpansion(boardId) {
|
||||
const memosContainer = document.getElementById(`memos_${boardId}`);
|
||||
const expandIcon = document.getElementById(`expandIcon_${boardId}`);
|
||||
const boardElement = document.getElementById(`board_${boardId}`);
|
||||
|
||||
if (memosContainer.classList.contains('expanded')) {
|
||||
memosContainer.classList.remove('expanded');
|
||||
expandIcon.style.transform = 'rotate(0deg)';
|
||||
boardElement.classList.remove('expanded');
|
||||
} else {
|
||||
memosContainer.classList.add('expanded');
|
||||
expandIcon.style.transform = 'rotate(180deg)';
|
||||
boardElement.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 처리
|
||||
function handleSearch() {
|
||||
currentSearchTerm = document.getElementById('searchInput').value.trim();
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// 날짜 필터 설정
|
||||
function setDateFilter(filter) {
|
||||
currentDateFilter = filter;
|
||||
|
||||
// 필터 버튼 활성화 상태 업데이트
|
||||
document.querySelectorAll('.filter-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// 이미지 모달 표시
|
||||
function showImageModal(imageUrl) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 modal flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="max-w-4xl max-h-4xl p-4">
|
||||
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg">
|
||||
</div>
|
||||
`;
|
||||
modal.onclick = () => modal.remove();
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 전역 함수 등록
|
||||
window.toggleBoardExpansion = toggleBoardExpansion;
|
||||
window.handleSearch = handleSearch;
|
||||
window.setDateFilter = setDateFilter;
|
||||
window.showImageModal = showImageModal;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1141
frontend/board.html
Normal file
1141
frontend/board.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,398 +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>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6; /* 하늘색 */
|
||||
--primary-dark: #2563eb; /* 진한 하늘색 */
|
||||
--success: #10b981; /* 초록색 */
|
||||
--warning: #f59e0b; /* 주황색 */
|
||||
--danger: #ef4444; /* 빨간색 */
|
||||
--gray-50: #f9fafb; /* 연한 회색 */
|
||||
--gray-100: #f3f4f6; /* 회색 */
|
||||
--gray-200: #e5e7eb; /* 중간 회색 */
|
||||
--gray-300: #d1d5db; /* 진한 회색 */
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: #d97706;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.calendar-item {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.calendar-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.deadline-urgent {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.deadline-warning {
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.deadline-normal {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<button onclick="goBack()" class="mr-4 text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-arrow-left text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-calendar-times text-2xl text-orange-500 mr-3"></i>
|
||||
<h1 class="text-xl font-semibold text-gray-800">캘린더</h1>
|
||||
<span class="ml-3 text-sm text-gray-500">마감 기한이 있는 일들</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
<i class="fas fa-chart-line mr-1"></i>대시보드
|
||||
</button>
|
||||
<span class="text-sm text-gray-600" id="currentUser"></span>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 페이지 설명 -->
|
||||
<div class="bg-orange-50 rounded-xl p-6 mb-8">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-calendar-times text-2xl text-orange-600 mr-3"></i>
|
||||
<h2 class="text-xl font-semibold text-orange-900">캘린더 관리</h2>
|
||||
</div>
|
||||
<p class="text-orange-800 mb-4">
|
||||
마감 기한이 있는 일들을 관리합니다. 우선순위에 따라 계획적으로 진행해보세요.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-red-900 mb-1">🚨 긴급</div>
|
||||
<div class="text-red-700">3일 이내 마감</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-orange-900 mb-1">⚠️ 주의</div>
|
||||
<div class="text-orange-700">1주일 이내 마감</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-blue-900 mb-1">📅 여유</div>
|
||||
<div class="text-blue-700">1주일 이상 남음</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 및 정렬 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<button onclick="filterCalendar('all')" class="filter-tab active px-4 py-2 rounded text-sm font-medium">전체</button>
|
||||
<button onclick="filterCalendar('urgent')" class="filter-tab px-4 py-2 rounded text-sm font-medium">긴급</button>
|
||||
<button onclick="filterCalendar('warning')" class="filter-tab px-4 py-2 rounded text-sm font-medium">주의</button>
|
||||
<button onclick="filterCalendar('normal')" class="filter-tab px-4 py-2 rounded text-sm font-medium">여유</button>
|
||||
<button onclick="filterCalendar('completed')" class="filter-tab px-4 py-2 rounded text-sm font-medium">완료</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="text-sm text-gray-600">정렬:</label>
|
||||
<select id="sortBy" class="border border-gray-300 rounded-lg px-3 py-1 text-sm">
|
||||
<option value="due_date">마감일 순</option>
|
||||
<option value="priority">우선순위 순</option>
|
||||
<option value="created_at">등록일 순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 캘린더 목록 -->
|
||||
<div class="bg-white rounded-xl shadow-sm">
|
||||
<div class="p-6 border-b">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
<i class="fas fa-list text-orange-500 mr-2"></i>마감 기한별 목록
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div id="calendarList" class="divide-y divide-gray-100">
|
||||
<!-- 캘린더 항목들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="p-12 text-center text-gray-500">
|
||||
<i class="fas fa-calendar-times text-4xl mb-4 opacity-50"></i>
|
||||
<p>아직 마감 기한이 설정된 일이 없습니다.</p>
|
||||
<p class="text-sm">메인 페이지에서 항목을 등록하고 마감 기한을 설정해보세요!</p>
|
||||
<button onclick="goBack()" class="mt-4 btn-warning px-6 py-2 rounded-lg">
|
||||
<i class="fas fa-arrow-left mr-2"></i>메인으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script>
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthStatus();
|
||||
loadCalendarItems();
|
||||
});
|
||||
|
||||
// 뒤로 가기
|
||||
function goBack() {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// 캘린더 항목 로드
|
||||
async function loadCalendarItems() {
|
||||
try {
|
||||
// API에서 캘린더 카테고리 항목들만 가져오기
|
||||
const items = await TodoAPI.getTodos(null, 'calendar');
|
||||
renderCalendarItems(items);
|
||||
} catch (error) {
|
||||
console.error('캘린더 항목 로드 실패:', error);
|
||||
renderCalendarItems([]);
|
||||
}
|
||||
}
|
||||
|
||||
// 캘린더 항목 렌더링
|
||||
function renderCalendarItems(items) {
|
||||
const calendarList = document.getElementById('calendarList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (items.length === 0) {
|
||||
calendarList.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
calendarList.innerHTML = items.map(item => `
|
||||
<div class="calendar-item p-6 ${getDeadlineClass(item.priority)}">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 우선순위 아이콘 -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center ${getPriorityColor(item.priority)}">
|
||||
<i class="fas ${getPriorityIcon(item.priority)} text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 (있는 경우) -->
|
||||
${item.image_urls && item.image_urls.length > 0 ? `
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex space-x-2">
|
||||
${item.image_urls.slice(0, 3).map(url => `
|
||||
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
|
||||
`).join('')}
|
||||
${item.image_urls.length > 3 ? `
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.title}</h4>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500 mb-2">
|
||||
<span class="${getDueDateColor(item.due_date)}">
|
||||
<i class="fas fa-calendar-times mr-1"></i>마감: ${formatDate(item.due_date)}
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getPriorityBadgeColor(item.priority)}">
|
||||
${getPriorityText(item.priority)}
|
||||
</span>
|
||||
<span class="ml-2 text-gray-500">
|
||||
${getDaysRemaining(item.due_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
${item.status !== 'completed' ? `
|
||||
<button onclick="completeCalendar('${item.id}')" class="text-green-500 hover:text-green-700" title="완료하기">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button onclick="extendDeadline('${item.id}')" class="text-orange-500 hover:text-orange-700" title="기한 연장">
|
||||
<i class="fas fa-calendar-plus"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="editCalendar('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 마감 기한별 클래스
|
||||
function getDeadlineClass(priority) {
|
||||
const classes = {
|
||||
urgent: 'deadline-urgent',
|
||||
warning: 'deadline-warning',
|
||||
normal: 'deadline-normal'
|
||||
};
|
||||
return classes[priority] || 'deadline-normal';
|
||||
}
|
||||
|
||||
// 우선순위별 색상
|
||||
function getPriorityColor(priority) {
|
||||
const colors = {
|
||||
urgent: 'bg-red-100 text-red-600',
|
||||
warning: 'bg-orange-100 text-orange-600',
|
||||
normal: 'bg-blue-100 text-blue-600'
|
||||
};
|
||||
return colors[priority] || 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
|
||||
// 우선순위별 아이콘
|
||||
function getPriorityIcon(priority) {
|
||||
const icons = {
|
||||
urgent: 'fa-exclamation-triangle',
|
||||
warning: 'fa-exclamation',
|
||||
normal: 'fa-calendar'
|
||||
};
|
||||
return icons[priority] || 'fa-circle';
|
||||
}
|
||||
|
||||
// 우선순위별 배지 색상
|
||||
function getPriorityBadgeColor(priority) {
|
||||
const colors = {
|
||||
urgent: 'bg-red-100 text-red-800',
|
||||
warning: 'bg-orange-100 text-orange-800',
|
||||
normal: 'bg-blue-100 text-blue-800'
|
||||
};
|
||||
return colors[priority] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
// 우선순위 텍스트
|
||||
function getPriorityText(priority) {
|
||||
const texts = {
|
||||
urgent: '긴급',
|
||||
warning: '주의',
|
||||
normal: '여유'
|
||||
};
|
||||
return texts[priority] || '일반';
|
||||
}
|
||||
|
||||
// 마감일 색상
|
||||
function getDueDateColor(dueDate) {
|
||||
const days = getDaysUntilDeadline(dueDate);
|
||||
if (days <= 3) return 'text-red-600 font-medium';
|
||||
if (days <= 7) return 'text-orange-600 font-medium';
|
||||
return 'text-gray-600';
|
||||
}
|
||||
|
||||
// 남은 일수 계산
|
||||
function getDaysUntilDeadline(dueDate) {
|
||||
const today = new Date();
|
||||
const deadline = new Date(dueDate);
|
||||
const diffTime = deadline - today;
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// 남은 일수 텍스트
|
||||
function getDaysRemaining(dueDate) {
|
||||
const days = getDaysUntilDeadline(dueDate);
|
||||
if (days < 0) return '기한 초과';
|
||||
if (days === 0) return '오늘 마감';
|
||||
if (days === 1) return '내일 마감';
|
||||
return `${days}일 남음`;
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '날짜 없음';
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '날짜 없음';
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
}
|
||||
|
||||
// 캘린더 완료
|
||||
function completeCalendar(id) {
|
||||
console.log('캘린더 완료:', id);
|
||||
// TODO: API 호출하여 상태를 'completed'로 변경
|
||||
}
|
||||
|
||||
// 기한 연장
|
||||
function extendDeadline(id) {
|
||||
console.log('기한 연장:', id);
|
||||
// TODO: 기한 연장 모달 표시
|
||||
}
|
||||
|
||||
// 캘린더 편집
|
||||
function editCalendar(id) {
|
||||
console.log('캘린더 편집:', id);
|
||||
// TODO: 편집 모달 또는 페이지로 이동
|
||||
}
|
||||
|
||||
// 필터링
|
||||
function filterCalendar(filter) {
|
||||
console.log('필터:', filter);
|
||||
// TODO: 필터에 따라 목록 재로드
|
||||
}
|
||||
|
||||
// 대시보드로 이동
|
||||
function goToDashboard() {
|
||||
window.location.href = 'dashboard.html';
|
||||
}
|
||||
|
||||
// 전역 함수 등록
|
||||
window.goToDashboard = goToDashboard;
|
||||
</script>
|
||||
<script src="static/js/api.js?v=20250921110800"></script>
|
||||
<script src="static/js/auth.js?v=20250921110800"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,604 +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>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6; /* 하늘색 */
|
||||
--primary-dark: #2563eb; /* 진한 하늘색 */
|
||||
--success: #10b981; /* 초록색 */
|
||||
--warning: #f59e0b; /* 주황색 */
|
||||
--danger: #ef4444; /* 빨간색 */
|
||||
--gray-50: #f9fafb; /* 연한 회색 */
|
||||
--gray-100: #f3f4f6; /* 회색 */
|
||||
--gray-200: #e5e7eb; /* 중간 회색 */
|
||||
--gray-300: #d1d5db; /* 진한 회색 */
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #059669;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checklist-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.checklist-item.completed {
|
||||
opacity: 0.7;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.checklist-item.completed .item-content {
|
||||
text-decoration: line-through;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.checkbox-custom {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-custom.checked {
|
||||
background-color: #10b981;
|
||||
border-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.checkbox-custom:hover {
|
||||
border-color: #10b981;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<button onclick="goBack()" class="mr-4 text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-arrow-left text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-check-square text-2xl text-green-500 mr-3"></i>
|
||||
<h1 class="text-xl font-semibold text-gray-800">체크리스트</h1>
|
||||
<span class="ml-3 text-sm text-gray-500">기한 없는 일들</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
<i class="fas fa-chart-line mr-1"></i>대시보드
|
||||
</button>
|
||||
<span class="text-sm text-gray-600" id="currentUser"></span>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 페이지 설명 -->
|
||||
<div class="bg-green-50 rounded-xl p-6 mb-8">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-check-square text-2xl text-green-600 mr-3"></i>
|
||||
<h2 class="text-xl font-semibold text-green-900">체크리스트 관리</h2>
|
||||
</div>
|
||||
<p class="text-green-800 mb-4">
|
||||
기한이 없는 일들을 관리합니다. 언제든 할 수 있는 일들을 체크해나가세요.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-green-900 mb-1">📝 할 일</div>
|
||||
<div class="text-green-700">아직 완료하지 않은 일들</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-green-900 mb-1">✅ 완료</div>
|
||||
<div class="text-green-700">완료한 일들</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-green-900 mb-1">📊 진행률</div>
|
||||
<div class="text-green-700" id="progressText">0% 완료</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 진행률 표시 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
<i class="fas fa-chart-line text-green-500 mr-2"></i>전체 진행률
|
||||
</h3>
|
||||
<div class="text-sm text-gray-600">
|
||||
<span id="completedCount">0</span> / <span id="totalCount">0</span> 완료
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
<div id="progressBar" class="bg-green-500 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 및 정렬 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<button onclick="filterChecklist('all')" class="filter-tab active px-4 py-2 rounded text-sm font-medium">전체</button>
|
||||
<button onclick="filterChecklist('active')" class="filter-tab px-4 py-2 rounded text-sm font-medium">할 일</button>
|
||||
<button onclick="filterChecklist('completed')" class="filter-tab px-4 py-2 rounded text-sm font-medium">완료</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="text-sm text-gray-600">정렬:</label>
|
||||
<select id="sortBy" class="border border-gray-300 rounded-lg px-3 py-1 text-sm">
|
||||
<option value="created_at">등록일 순</option>
|
||||
<option value="completed_at">완료일 순</option>
|
||||
<option value="alphabetical">가나다 순</option>
|
||||
</select>
|
||||
<button onclick="clearCompleted()" class="text-sm text-red-600 hover:text-red-800">
|
||||
<i class="fas fa-trash mr-1"></i>완료된 항목 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 체크리스트 목록 -->
|
||||
<div class="bg-white rounded-xl shadow-sm">
|
||||
<div class="p-6 border-b">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
<i class="fas fa-list text-green-500 mr-2"></i>체크리스트 목록
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div id="checklistList" class="divide-y divide-gray-100">
|
||||
<!-- 체크리스트 항목들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="p-12 text-center text-gray-500">
|
||||
<i class="fas fa-check-square text-4xl mb-4 opacity-50"></i>
|
||||
<p>아직 체크리스트 항목이 없습니다.</p>
|
||||
<p class="text-sm">메인 페이지에서 기한 없는 항목을 등록해보세요!</p>
|
||||
<button onclick="goBack()" class="mt-4 btn-success px-6 py-2 rounded-lg">
|
||||
<i class="fas fa-arrow-left mr-2"></i>메인으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script>
|
||||
let checklistItems = [];
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthStatus();
|
||||
loadChecklistItems();
|
||||
});
|
||||
|
||||
// 뒤로 가기
|
||||
function goBack() {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// 체크리스트 항목 로드
|
||||
async function loadChecklistItems() {
|
||||
try {
|
||||
// API에서 체크리스트 카테고리 항목들만 가져오기
|
||||
const items = await TodoAPI.getTodos(null, 'checklist');
|
||||
console.log('🔍 체크리스트 API 응답 데이터:', items);
|
||||
|
||||
// 각 항목의 이미지 데이터 확인
|
||||
items.forEach((item, index) => {
|
||||
console.log(`📋 체크리스트 항목 ${index + 1}:`, {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
image_urls: item.image_urls,
|
||||
image_urls_type: typeof item.image_urls,
|
||||
image_urls_length: item.image_urls ? item.image_urls.length : 'null'
|
||||
});
|
||||
});
|
||||
|
||||
checklistItems = items;
|
||||
} catch (error) {
|
||||
console.error('체크리스트 항목 로드 실패:', error);
|
||||
checklistItems = [];
|
||||
}
|
||||
|
||||
renderChecklistItems(checklistItems);
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
// 체크리스트 항목 렌더링
|
||||
function renderChecklistItems(items) {
|
||||
const checklistList = document.getElementById('checklistList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (items.length === 0) {
|
||||
checklistList.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
checklistList.innerHTML = items.map(item => `
|
||||
<div class="checklist-item p-6 ${item.completed ? 'completed' : ''}">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 체크박스 -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="checkbox-custom ${item.completed ? 'checked' : ''}" onclick="toggleComplete('${item.id}')">
|
||||
${item.completed ? '<i class="fas fa-check text-xs"></i>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 (있는 경우) -->
|
||||
${item.image_urls && item.image_urls.length > 0 ? `
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex space-x-2">
|
||||
${item.image_urls.slice(0, 3).map(url => `
|
||||
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
|
||||
`).join('')}
|
||||
${item.image_urls.length > 3 ? `
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="item-content text-gray-900 font-medium mb-2">${item.title}</h4>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
|
||||
</span>
|
||||
${item.completed && item.completed_at ? `
|
||||
<span class="text-green-600">
|
||||
<i class="fas fa-check mr-1"></i>완료: ${formatDate(item.completed_at)}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
<!-- 카테고리 변경 버튼 -->
|
||||
<div class="relative">
|
||||
<button onclick="showCategoryMenu('${item.id}')" class="text-gray-400 hover:text-purple-500" title="카테고리 변경">
|
||||
<i class="fas fa-exchange-alt"></i>
|
||||
</button>
|
||||
<div id="categoryMenu-${item.id}" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border z-10">
|
||||
<div class="py-2">
|
||||
<button onclick="changeCategory('${item.id}', 'todo')" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">
|
||||
<i class="fas fa-calendar-day mr-2 text-blue-500"></i>Todo로 변경
|
||||
</button>
|
||||
<button onclick="changeCategory('${item.id}', 'calendar')" class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-orange-50 hover:text-orange-600">
|
||||
<i class="fas fa-calendar-times mr-2 text-orange-500"></i>캘린더로 변경
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="editChecklist('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="deleteChecklist('${item.id}')" class="text-gray-400 hover:text-red-500" title="삭제하기">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 완료 상태 토글
|
||||
function toggleComplete(id) {
|
||||
const item = checklistItems.find(item => item.id === id);
|
||||
if (item) {
|
||||
if (!item.completed) {
|
||||
// 완료 처리 - 애니메이션 후 제거
|
||||
item.completed = true;
|
||||
item.completed_at = new Date().toISOString().split('T')[0];
|
||||
|
||||
// 즉시 완료 상태로 렌더링
|
||||
renderChecklistItems(checklistItems);
|
||||
updateProgress();
|
||||
|
||||
// 1.5초 후 항목 제거
|
||||
setTimeout(() => {
|
||||
const itemIndex = checklistItems.findIndex(i => i.id === id);
|
||||
if (itemIndex !== -1) {
|
||||
checklistItems.splice(itemIndex, 1);
|
||||
renderChecklistItems(checklistItems);
|
||||
updateProgress();
|
||||
|
||||
// 완료 메시지 표시
|
||||
showCompletionToast('항목이 완료되었습니다! 🎉');
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
// API 호출하여 상태 업데이트
|
||||
updateTodoStatus(id, 'completed');
|
||||
console.log('체크리스트 완료 처리:', id);
|
||||
} else {
|
||||
// 완료 취소는 허용하지 않음 (이미 완료된 항목은 제거되므로)
|
||||
console.log('완료된 항목은 취소할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 상태 업데이트 (API 호출)
|
||||
async function updateTodoStatus(id, status) {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
console.error('인증 토큰이 없습니다!');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`http://localhost:9000/api/todos/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ status: status })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`Todo ${id} 상태가 ${status}로 업데이트되었습니다.`);
|
||||
} catch (error) {
|
||||
console.error('Todo 상태 업데이트 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 완료 토스트 메시지 표시
|
||||
function showCompletionToast(message) {
|
||||
// 기존 토스트가 있으면 제거
|
||||
const existingToast = document.getElementById('completionToast');
|
||||
if (existingToast) {
|
||||
existingToast.remove();
|
||||
}
|
||||
|
||||
// 새 토스트 생성
|
||||
const toast = document.createElement('div');
|
||||
toast.id = 'completionToast';
|
||||
toast.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-x-full';
|
||||
toast.innerHTML = `
|
||||
<div class="flex items-center space-x-2">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 애니메이션으로 나타나기
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-x-full');
|
||||
}, 100);
|
||||
|
||||
// 3초 후 사라지기
|
||||
setTimeout(() => {
|
||||
toast.classList.add('translate-x-full');
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 진행률 업데이트
|
||||
function updateProgress() {
|
||||
const total = checklistItems.length;
|
||||
const completed = checklistItems.filter(item => item.completed).length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
document.getElementById('totalCount').textContent = total;
|
||||
document.getElementById('completedCount').textContent = completed;
|
||||
document.getElementById('progressText').textContent = `${percentage}% 완료`;
|
||||
document.getElementById('progressBar').style.width = `${percentage}%`;
|
||||
}
|
||||
|
||||
// 완료된 항목 삭제
|
||||
function clearCompleted() {
|
||||
if (confirm('완료된 모든 항목을 삭제하시겠습니까?')) {
|
||||
checklistItems = checklistItems.filter(item => !item.completed);
|
||||
renderChecklistItems(checklistItems);
|
||||
updateProgress();
|
||||
|
||||
// TODO: API 호출하여 완료된 항목들 삭제
|
||||
console.log('완료된 항목들 삭제');
|
||||
}
|
||||
}
|
||||
|
||||
// 체크리스트 편집
|
||||
function editChecklist(id) {
|
||||
console.log('체크리스트 편집:', id);
|
||||
// TODO: 편집 모달 또는 페이지로 이동
|
||||
}
|
||||
|
||||
// 체크리스트 삭제
|
||||
function deleteChecklist(id) {
|
||||
if (confirm('이 항목을 삭제하시겠습니까?')) {
|
||||
checklistItems = checklistItems.filter(item => item.id !== id);
|
||||
renderChecklistItems(checklistItems);
|
||||
updateProgress();
|
||||
|
||||
// TODO: API 호출하여 항목 삭제
|
||||
console.log('체크리스트 삭제:', id);
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '날짜 없음';
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '날짜 없음';
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
}
|
||||
|
||||
// 필터링
|
||||
function filterChecklist(filter) {
|
||||
let filteredItems = checklistItems;
|
||||
|
||||
if (filter === 'active') {
|
||||
filteredItems = checklistItems.filter(item => !item.completed);
|
||||
} else if (filter === 'completed') {
|
||||
filteredItems = checklistItems.filter(item => item.completed);
|
||||
}
|
||||
|
||||
renderChecklistItems(filteredItems);
|
||||
|
||||
// 필터 탭 활성화 상태 업데이트
|
||||
document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
console.log('필터:', filter);
|
||||
}
|
||||
|
||||
// 카테고리 메뉴 표시/숨김
|
||||
function showCategoryMenu(itemId) {
|
||||
// 다른 메뉴들 숨기기
|
||||
document.querySelectorAll('[id^="categoryMenu-"]').forEach(menu => {
|
||||
if (menu.id !== `categoryMenu-${itemId}`) {
|
||||
menu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 해당 메뉴 토글
|
||||
const menu = document.getElementById(`categoryMenu-${itemId}`);
|
||||
if (menu) {
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 변경
|
||||
async function changeCategory(itemId, newCategory) {
|
||||
try {
|
||||
// 날짜 입력 받기 (todo나 calendar인 경우)
|
||||
let dueDate = null;
|
||||
if (newCategory === 'todo' || newCategory === 'calendar') {
|
||||
const dateInput = prompt(
|
||||
newCategory === 'todo' ?
|
||||
'시작 날짜를 입력하세요 (YYYY-MM-DD):' :
|
||||
'마감 날짜를 입력하세요 (YYYY-MM-DD):'
|
||||
);
|
||||
|
||||
if (!dateInput) {
|
||||
return; // 취소
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateInput)) {
|
||||
alert('올바른 날짜 형식을 입력해주세요 (YYYY-MM-DD)');
|
||||
return;
|
||||
}
|
||||
|
||||
dueDate = dateInput + 'T09:00:00Z';
|
||||
}
|
||||
|
||||
// API 호출하여 카테고리 변경
|
||||
const updateData = {
|
||||
category: newCategory
|
||||
};
|
||||
|
||||
if (dueDate) {
|
||||
updateData.due_date = dueDate;
|
||||
}
|
||||
|
||||
await TodoAPI.updateTodo(itemId, updateData);
|
||||
|
||||
// 성공 메시지
|
||||
const categoryNames = {
|
||||
'todo': 'Todo',
|
||||
'calendar': '캘린더'
|
||||
};
|
||||
|
||||
alert(`항목이 ${categoryNames[newCategory]}로 이동되었습니다!`);
|
||||
|
||||
// 메뉴 숨기기
|
||||
document.getElementById(`categoryMenu-${itemId}`).classList.add('hidden');
|
||||
|
||||
// 페이지 새로고침하여 변경된 항목 제거
|
||||
await loadChecklistItems();
|
||||
|
||||
} catch (error) {
|
||||
console.error('카테고리 변경 실패:', error);
|
||||
alert('카테고리 변경에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 대시보드로 이동
|
||||
function goToDashboard() {
|
||||
window.location.href = 'dashboard.html';
|
||||
}
|
||||
|
||||
// 전역 함수 등록
|
||||
window.goToDashboard = goToDashboard;
|
||||
window.showCategoryMenu = showCategoryMenu;
|
||||
window.changeCategory = changeCategory;
|
||||
|
||||
// 문서 클릭 시 메뉴 숨기기
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.relative')) {
|
||||
document.querySelectorAll('[id^="categoryMenu-"]').forEach(menu => {
|
||||
menu.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="static/js/api.js?v=20250921110800"></script>
|
||||
<script src="static/js/auth.js?v=20250921110800"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,623 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>INDEX - Todo Project</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-300: #d1d5db;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 분류 카드 스타일 */
|
||||
.classify-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.classify-card:hover {
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.classify-card.selected {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* 분류 버튼 스타일 */
|
||||
.classify-btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.classify-btn.todo {
|
||||
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
|
||||
color: #1e40af;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.classify-btn.todo:hover {
|
||||
background: linear-gradient(135deg, #bfdbfe, #93c5fd);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.classify-btn.calendar {
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
color: #92400e;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.classify-btn.calendar:hover {
|
||||
background: linear-gradient(135deg, #fde68a, #fcd34d);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.classify-btn.checklist {
|
||||
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
|
||||
color: #065f46;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.classify-btn.checklist:hover {
|
||||
background: linear-gradient(135deg, #a7f3d0, #6ee7b7);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 스마트 제안 스타일 */
|
||||
.smart-suggestion {
|
||||
background: linear-gradient(135deg, #f3e8ff, #e9d5ff);
|
||||
border: 2px solid #8b5cf6;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* 태그 스타일 */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.tag.selected {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 애니메이션 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* 모바일 최적화 */
|
||||
@media (max-width: 768px) {
|
||||
.classify-btn {
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.classify-card {
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<button onclick="goBack()" class="mr-4 text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-arrow-left text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-inbox text-2xl text-purple-500 mr-3"></i>
|
||||
<h1 class="text-xl font-semibold text-gray-800">INDEX</h1>
|
||||
<span class="ml-3 px-2 py-1 bg-red-100 text-red-800 text-sm rounded-full" id="pendingCount">0</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
<i class="fas fa-chart-line mr-1"></i>대시보드
|
||||
</button>
|
||||
<button onclick="selectAll()" class="text-gray-600 hover:text-gray-800 text-sm">
|
||||
<i class="fas fa-check-square mr-1"></i>전체선택
|
||||
</button>
|
||||
<span class="text-sm text-gray-600" id="currentUser"></span>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 상단 통계 및 필터 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<!-- 통계 카드들 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-inbox text-purple-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-600">분류 대기</p>
|
||||
<p class="text-2xl font-bold text-gray-900" id="totalPending">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-calendar-day text-blue-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-600">Todo 이동</p>
|
||||
<p class="text-2xl font-bold text-gray-900" id="todoMoved">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-calendar-times text-orange-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-600">캘린더 이동</p>
|
||||
<p class="text-2xl font-bold text-gray-900" id="calendarMoved">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-check-square text-green-600 text-xl"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-600">체크리스트 이동</p>
|
||||
<p class="text-2xl font-bold text-gray-900" id="checklistMoved">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 및 정렬 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button onclick="filterItems('all')" class="filter-btn active px-4 py-2 rounded-lg text-sm font-medium">전체</button>
|
||||
<button onclick="filterItems('upload')" class="filter-btn px-4 py-2 rounded-lg text-sm font-medium">업로드</button>
|
||||
<button onclick="filterItems('mail')" class="filter-btn px-4 py-2 rounded-lg text-sm font-medium">메일</button>
|
||||
<button onclick="filterItems('suggested')" class="filter-btn px-4 py-2 rounded-lg text-sm font-medium">제안 있음</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<select id="sortBy" class="border border-gray-300 rounded-lg px-3 py-2 text-sm">
|
||||
<option value="newest">최신순</option>
|
||||
<option value="oldest">오래된순</option>
|
||||
<option value="suggested">제안순</option>
|
||||
</select>
|
||||
|
||||
<button onclick="batchClassify()" class="btn-primary px-4 py-2 rounded-lg text-sm" disabled id="batchBtn">
|
||||
<i class="fas fa-layer-group mr-1"></i>일괄 분류
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분류 대기 항목들 -->
|
||||
<div class="space-y-4" id="classifyItems">
|
||||
<!-- 항목들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 빈 상태 -->
|
||||
<div id="emptyState" class="hidden text-center py-16">
|
||||
<i class="fas fa-inbox text-6xl text-gray-300 mb-4"></i>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">분류할 항목이 없습니다</h3>
|
||||
<p class="text-gray-500 mb-6">새로운 항목을 업로드하거나 메일을 받으면 여기에 표시됩니다.</p>
|
||||
<button onclick="goToDashboard()" class="btn-primary px-6 py-3 rounded-lg">
|
||||
<i class="fas fa-plus mr-2"></i>새 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/api.js?v=2"></script>
|
||||
<script src="static/js/auth.js?v=2"></script>
|
||||
<script>
|
||||
let pendingItems = [];
|
||||
let selectedItems = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthStatus();
|
||||
loadPendingItems();
|
||||
updateStats();
|
||||
});
|
||||
|
||||
// 분류 대기 항목 로드
|
||||
function loadPendingItems() {
|
||||
// 분류되지 않은 항목들을 API에서 가져와야 함
|
||||
// 현재는 빈 배열로 설정 (분류 기능 미구현)
|
||||
pendingItems = [];
|
||||
|
||||
renderItems();
|
||||
}
|
||||
|
||||
// 항목들 렌더링
|
||||
function renderItems() {
|
||||
const container = document.getElementById('classifyItems');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
// 필터링
|
||||
let filteredItems = pendingItems;
|
||||
if (currentFilter !== 'all') {
|
||||
filteredItems = pendingItems.filter(item => {
|
||||
if (currentFilter === 'suggested') return item.suggested;
|
||||
return item.type === currentFilter;
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
container.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
container.innerHTML = filteredItems.map(item => `
|
||||
<div class="classify-card p-6 ${selectedItems.includes(item.id) ? 'selected' : ''}" data-id="${item.id}">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 선택 체크박스 -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<input type="checkbox" class="w-5 h-5 text-blue-600 rounded"
|
||||
${selectedItems.includes(item.id) ? 'checked' : ''}
|
||||
onchange="toggleSelection(${item.id})">
|
||||
</div>
|
||||
|
||||
<!-- 타입 아이콘 -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-12 h-12 rounded-lg flex items-center justify-center ${item.type === 'upload' ? 'bg-blue-100' : 'bg-green-100'}">
|
||||
<i class="fas ${item.type === 'upload' ? 'fa-camera text-blue-600' : 'fa-envelope text-green-600'} text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 (있는 경우) -->
|
||||
${item.image_urls && item.image_urls.length > 0 ? `
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex space-x-2">
|
||||
${item.image_urls.slice(0, 2).map(url => `
|
||||
<img src="${url}" class="w-20 h-20 object-cover rounded-lg" alt="첨부 사진">
|
||||
`).join('')}
|
||||
${item.image_urls.length > 2 ? `
|
||||
<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-sm text-gray-500">+${item.image_urls.length - 2}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-lg font-medium text-gray-900 mb-2">${item.content}</h4>
|
||||
|
||||
<!-- 메타 정보 -->
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-3">
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>${formatDate(item.created_at)}
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-source mr-1"></i>${item.source}
|
||||
</span>
|
||||
${item.sender ? `
|
||||
<span>
|
||||
<i class="fas fa-user mr-1"></i>${item.sender}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- 태그 -->
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
${item.tags.map(tag => `<span class="tag">#${tag}</span>`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- 스마트 제안 -->
|
||||
${item.suggested ? `
|
||||
<div class="smart-suggestion">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-magic text-purple-600 mr-2"></i>
|
||||
<span class="text-sm font-medium text-purple-800">
|
||||
AI 제안: <strong>${getSuggestionText(item.suggested)}</strong>
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-purple-600">(${Math.round(item.confidence * 100)}% 확신)</span>
|
||||
</div>
|
||||
<button onclick="acceptSuggestion('${item.id}', '${item.suggested}')"
|
||||
class="text-xs bg-purple-600 text-white px-3 py-1 rounded-full hover:bg-purple-700">
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분류 버튼들 -->
|
||||
<div class="mt-6 flex flex-wrap gap-3 justify-center">
|
||||
<button onclick="classifyItem('${item.id}', 'todo')" class="classify-btn todo">
|
||||
<i class="fas fa-calendar-day mr-2"></i>Todo
|
||||
<div class="text-xs opacity-75">시작 날짜</div>
|
||||
</button>
|
||||
<button onclick="classifyItem('${item.id}', 'calendar')" class="classify-btn calendar">
|
||||
<i class="fas fa-calendar-times mr-2"></i>캘린더
|
||||
<div class="text-xs opacity-75">마감 기한</div>
|
||||
</button>
|
||||
<button onclick="classifyItem('${item.id}', 'checklist')" class="classify-btn checklist">
|
||||
<i class="fas fa-check-square mr-2"></i>체크리스트
|
||||
<div class="text-xs opacity-75">기한 없음</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 애니메이션 적용
|
||||
container.querySelectorAll('.classify-card').forEach((card, index) => {
|
||||
card.style.animationDelay = `${index * 0.1}s`;
|
||||
card.classList.add('fade-in');
|
||||
});
|
||||
}
|
||||
|
||||
// 항목 선택 토글
|
||||
function toggleSelection(id) {
|
||||
const index = selectedItems.indexOf(id);
|
||||
if (index > -1) {
|
||||
selectedItems.splice(index, 1);
|
||||
} else {
|
||||
selectedItems.push(id);
|
||||
}
|
||||
|
||||
updateBatchButton();
|
||||
renderItems();
|
||||
}
|
||||
|
||||
// 전체 선택
|
||||
function selectAll() {
|
||||
if (selectedItems.length === pendingItems.length) {
|
||||
selectedItems = [];
|
||||
} else {
|
||||
selectedItems = pendingItems.map(item => item.id);
|
||||
}
|
||||
|
||||
updateBatchButton();
|
||||
renderItems();
|
||||
}
|
||||
|
||||
// 일괄 분류 버튼 업데이트
|
||||
function updateBatchButton() {
|
||||
const batchBtn = document.getElementById('batchBtn');
|
||||
if (selectedItems.length > 0) {
|
||||
batchBtn.disabled = false;
|
||||
batchBtn.textContent = `${selectedItems.length}개 일괄 분류`;
|
||||
} else {
|
||||
batchBtn.disabled = true;
|
||||
batchBtn.innerHTML = '<i class="fas fa-layer-group mr-1"></i>일괄 분류';
|
||||
}
|
||||
}
|
||||
|
||||
// 개별 항목 분류
|
||||
function classifyItem(id, category) {
|
||||
const item = pendingItems.find(item => item.id === id);
|
||||
if (!item) return;
|
||||
|
||||
// 애니메이션 효과
|
||||
const card = document.querySelector(`[data-id="${id}"]`);
|
||||
card.style.transform = 'scale(0.95)';
|
||||
card.style.opacity = '0.7';
|
||||
|
||||
setTimeout(() => {
|
||||
// 항목 제거
|
||||
pendingItems = pendingItems.filter(item => item.id !== id);
|
||||
selectedItems = selectedItems.filter(itemId => itemId !== id);
|
||||
|
||||
// UI 업데이트
|
||||
renderItems();
|
||||
updateStats();
|
||||
updateBatchButton();
|
||||
|
||||
// 성공 메시지
|
||||
showToast(`"${item.content}"이(가) ${getSuggestionText(category)}(으)로 이동되었습니다.`, 'success');
|
||||
|
||||
// TODO: API 호출하여 실제 분류 처리
|
||||
console.log(`항목 ${id}을(를) ${category}로 분류`);
|
||||
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 제안 수락
|
||||
function acceptSuggestion(id, category) {
|
||||
classifyItem(id, category);
|
||||
}
|
||||
|
||||
// 일괄 분류
|
||||
function batchClassify() {
|
||||
if (selectedItems.length === 0) return;
|
||||
|
||||
// 일괄 분류 모달 또는 드롭다운 표시
|
||||
const category = prompt(`선택된 ${selectedItems.length}개 항목을 어디로 분류하시겠습니까?\n1. Todo\n2. 캘린더\n3. 체크리스트\n\n번호를 입력하세요:`);
|
||||
|
||||
const categories = { '1': 'todo', '2': 'calendar', '3': 'checklist' };
|
||||
const selectedCategory = categories[category];
|
||||
|
||||
if (selectedCategory) {
|
||||
selectedItems.forEach(id => {
|
||||
setTimeout(() => classifyItem(id, selectedCategory), Math.random() * 500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 필터링
|
||||
function filterItems(filter) {
|
||||
currentFilter = filter;
|
||||
|
||||
// 필터 버튼 활성화 상태 업데이트
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.remove('active', 'bg-blue-600', 'text-white');
|
||||
btn.classList.add('text-gray-600', 'bg-gray-100');
|
||||
});
|
||||
|
||||
event.target.classList.add('active', 'bg-blue-600', 'text-white');
|
||||
event.target.classList.remove('text-gray-600', 'bg-gray-100');
|
||||
|
||||
renderItems();
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStats() {
|
||||
document.getElementById('totalPending').textContent = pendingItems.length;
|
||||
document.getElementById('pendingCount').textContent = pendingItems.length;
|
||||
|
||||
// TODO: 실제 이동된 항목 수 계산
|
||||
document.getElementById('todoMoved').textContent = '5';
|
||||
document.getElementById('calendarMoved').textContent = '3';
|
||||
document.getElementById('checklistMoved').textContent = '7';
|
||||
}
|
||||
|
||||
// 유틸리티 함수들
|
||||
function getSuggestionText(category) {
|
||||
const texts = {
|
||||
'todo': 'Todo',
|
||||
'calendar': '캘린더',
|
||||
'checklist': '체크리스트'
|
||||
};
|
||||
return texts[category] || '미분류';
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '날짜 없음';
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '날짜 없음';
|
||||
|
||||
const now = new Date();
|
||||
const diffTime = now - date;
|
||||
const diffHours = Math.floor(diffTime / (1000 * 60 * 60));
|
||||
|
||||
if (diffHours < 1) return '방금 전';
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
// 간단한 토스트 메시지 (실제로는 더 예쁜 토스트 UI 구현)
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
|
||||
// 임시 알림
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
|
||||
type === 'success' ? 'bg-green-500' : 'bg-blue-500'
|
||||
}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 네비게이션 함수들
|
||||
function goBack() {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
function goToDashboard() {
|
||||
window.location.href = 'dashboard.html';
|
||||
}
|
||||
|
||||
// 전역 함수 등록
|
||||
window.toggleSelection = toggleSelection;
|
||||
window.selectAll = selectAll;
|
||||
window.classifyItem = classifyItem;
|
||||
window.acceptSuggestion = acceptSuggestion;
|
||||
window.batchClassify = batchClassify;
|
||||
window.filterItems = filterItems;
|
||||
window.goBack = goBack;
|
||||
window.goToDashboard = goToDashboard;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
798
frontend/inbox.html
Normal file
798
frontend/inbox.html
Normal file
@@ -0,0 +1,798 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>수신함 - Todo Project</title>
|
||||
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--parchment: #f7f3e9;
|
||||
--parchment-dark: #f0ead6;
|
||||
--ink: #2c1810;
|
||||
--ink-light: #5d4e37;
|
||||
--sepia: #8b7355;
|
||||
--gold: #d4af37;
|
||||
--shadow: rgba(139, 115, 85, 0.2);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
background: linear-gradient(135deg, #f7f3e9 0%, #f0ead6 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.parchment-container {
|
||||
background: var(--parchment);
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, rgba(139, 115, 85, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(139, 115, 85, 0.05) 0%, transparent 50%);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 8px 32px var(--shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.parchment-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, var(--gold), var(--sepia), var(--gold));
|
||||
border-radius: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.header-vintage {
|
||||
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
|
||||
border-bottom: 3px solid var(--gold);
|
||||
box-shadow: 0 2px 10px var(--shadow);
|
||||
}
|
||||
|
||||
.memo-item {
|
||||
background: var(--parchment-dark);
|
||||
border: 1px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.memo-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px var(--shadow);
|
||||
}
|
||||
|
||||
.todo-button {
|
||||
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
|
||||
color: var(--parchment);
|
||||
border: 2px solid var(--gold);
|
||||
border-radius: 20px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
.todo-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
background: linear-gradient(135deg, var(--ink-light), var(--ink));
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
background: var(--gold);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--gold);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.edit-button:hover {
|
||||
background: var(--sepia);
|
||||
border-color: var(--sepia);
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background: transparent;
|
||||
color: var(--ink-light);
|
||||
border: 1px solid var(--sepia);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: var(--sepia);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 0.5rem;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sepia);
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.date-modal {
|
||||
background: rgba(44, 24, 16, 0.8);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.date-input {
|
||||
background: var(--parchment);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="header-vintage">
|
||||
<div class="max-w-4xl mx-auto px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-2xl font-semibold" style="color: var(--ink);">
|
||||
<i class="fas fa-inbox mr-3" style="color: var(--gold);"></i>
|
||||
수신함
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="upload.html" class="todo-button">
|
||||
<i class="fas fa-feather-alt mr-2"></i>메모
|
||||
</a>
|
||||
<a href="inbox.html" class="todo-button" style="background: var(--gold); border-color: var(--gold); color: var(--ink);">
|
||||
<i class="fas fa-inbox mr-2"></i>수신함
|
||||
</a>
|
||||
<a href="todo-list.html" class="todo-button">
|
||||
<i class="fas fa-tasks mr-2"></i>Todo 목록
|
||||
</a>
|
||||
<a href="board.html" class="todo-button">
|
||||
<i class="fas fa-clipboard mr-2"></i>보드
|
||||
</a>
|
||||
<a href="archive.html" class="todo-button">
|
||||
<i class="fas fa-archive mr-2"></i>아카이브
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<div class="parchment-container p-6">
|
||||
<div id="memoList">
|
||||
<!-- 메모 목록이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="hidden text-center py-12">
|
||||
<i class="fas fa-inbox text-6xl mb-4" style="color: var(--sepia); opacity: 0.5;"></i>
|
||||
<h3 class="text-xl font-medium mb-2" style="color: var(--ink-light);">수신함이 비어있습니다</h3>
|
||||
<p class="text-sm mb-6" style="color: var(--sepia);">새로운 메모를 작성해보세요</p>
|
||||
<a href="upload.html" class="todo-button">
|
||||
<i class="fas fa-feather-alt mr-2"></i>첫 메모 작성하기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 변환 모달 -->
|
||||
<div id="conversionModal" class="hidden fixed inset-0 date-modal flex items-center justify-center z-50">
|
||||
<div class="parchment-container p-6 max-w-md w-full mx-4">
|
||||
<h3 id="modalTitle" class="text-lg font-semibold mb-4 text-center" style="color: var(--ink);">
|
||||
<i id="modalIcon" class="mr-2" style="color: var(--gold);"></i>
|
||||
<span id="modalText"></span>
|
||||
</h3>
|
||||
|
||||
<!-- Todo 변환 시 날짜 선택 -->
|
||||
<div id="todoDateSection" class="mb-4 hidden">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">시작 날짜</label>
|
||||
<input type="date" id="todoStartDate" class="date-input w-full">
|
||||
</div>
|
||||
|
||||
<!-- 보드 변환 시 제목 입력 -->
|
||||
<div id="boardTitleSection" class="mb-4 hidden">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">보드 제목</label>
|
||||
<input type="text" id="boardTitle" class="date-input w-full" placeholder="예: 9월 선일정밀 가공품">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="confirmConversion()" class="todo-button flex-1">
|
||||
<i class="fas fa-check mr-2"></i>확인
|
||||
</button>
|
||||
<button onclick="closeConversionModal()" class="todo-button flex-1" style="background: var(--sepia);">
|
||||
<i class="fas fa-times mr-2"></i>취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 편집 모달 -->
|
||||
<div id="editModal" class="hidden fixed inset-0 date-modal flex items-center justify-center z-50">
|
||||
<div class="parchment-container p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h3 class="text-lg font-semibold mb-4 text-center" style="color: var(--ink);">
|
||||
<i class="fas fa-edit mr-2" style="color: var(--gold);"></i>
|
||||
메모 편집
|
||||
</h3>
|
||||
|
||||
<form id="editForm" class="space-y-4">
|
||||
<!-- 내용 입력 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">내용</label>
|
||||
<textarea id="editDescription" rows="6" class="w-full px-3 py-2 border rounded-lg resize-none"
|
||||
style="border-color: var(--sepia); background: white; color: var(--ink);"
|
||||
placeholder="내용을 입력하세요" required></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 기존 이미지 -->
|
||||
<div id="existingImages" class="hidden">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">기존 이미지</label>
|
||||
<div id="existingImageGrid" class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- 새 이미지 추가 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">새 이미지 추가</label>
|
||||
<div class="space-y-3">
|
||||
<!-- 데스크톱: 파일 선택 -->
|
||||
<div class="desktop-upload">
|
||||
<input type="file" id="editImageInput" multiple accept="image/*" class="hidden">
|
||||
<button type="button" onclick="document.getElementById('editImageInput').click()"
|
||||
class="w-full py-3 px-4 border-2 border-dashed rounded-lg transition-colors"
|
||||
style="border-color: var(--sepia); color: var(--ink-light);"
|
||||
onmouseover="this.style.borderColor='var(--gold)'; this.style.backgroundColor='var(--parchment-dark)'"
|
||||
onmouseout="this.style.borderColor='var(--sepia)'; this.style.backgroundColor='transparent'">
|
||||
<i class="fas fa-images mr-2"></i>이미지 선택 (최대 5장)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 모바일: 카메라/갤러리 -->
|
||||
<div class="mobile-upload hidden">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button type="button" onclick="captureEditImage()" class="photo-button">
|
||||
<i class="fas fa-camera mr-2"></i>카메라
|
||||
</button>
|
||||
<button type="button" onclick="document.getElementById('editImageInput').click()" class="photo-button">
|
||||
<i class="fas fa-images mr-2"></i>갤러리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 새 이미지 미리보기 -->
|
||||
<div id="newImagePreview" class="hidden mt-3">
|
||||
<div id="newImageGrid" class="grid grid-cols-2 md:grid-cols-3 gap-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeEditModal()" class="cancel-button">
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" class="todo-button">
|
||||
<i class="fas fa-save mr-2"></i>저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script>
|
||||
let currentMemoId = null;
|
||||
let currentConversionType = null;
|
||||
let currentEditMemo = null;
|
||||
let newEditImages = [];
|
||||
let existingImages = [];
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 인증 확인
|
||||
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
loadMemos();
|
||||
});
|
||||
|
||||
// 메모 목록 로드
|
||||
async function loadMemos() {
|
||||
try {
|
||||
const memos = await TodoAPI.getTodos(null, 'memo');
|
||||
renderMemos(memos);
|
||||
} catch (error) {
|
||||
console.error('메모 로드 실패:', error);
|
||||
showEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
// 메모 목록 렌더링
|
||||
function renderMemos(memos) {
|
||||
const memoList = document.getElementById('memoList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (!memos || memos.length === 0) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
memoList.innerHTML = memos.map(memo => {
|
||||
const createdAt = new Date(memo.created_at);
|
||||
const timeAgo = getTimeAgo(createdAt);
|
||||
const hasImages = memo.image_urls && memo.image_urls.length > 0;
|
||||
|
||||
return `
|
||||
<div class="memo-item">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
${memo.title ? `<h3 class="font-medium text-lg mb-2" style="color: var(--ink);">${memo.title}</h3>` : ''}
|
||||
<p class="text-sm mb-2" style="color: var(--ink-light);">
|
||||
${memo.description || '내용 없음'}
|
||||
</p>
|
||||
<div class="flex items-center text-xs" style="color: var(--sepia);">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span>${timeAgo}</span>
|
||||
${hasImages ? `<i class="fas fa-images ml-3 mr-1"></i><span>${memo.image_urls.length}장</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2 ml-4">
|
||||
<button onclick="openEditModal('${memo.id}')" class="edit-button" title="편집">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button onclick="openConversionModal('${memo.id}', 'todo')" class="todo-button">
|
||||
<i class="fas fa-tasks mr-2"></i>Todo로
|
||||
</button>
|
||||
<button onclick="openConversionModal('${memo.id}', 'board')" class="todo-button">
|
||||
<i class="fas fa-clipboard mr-2"></i>보드로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${hasImages ? `
|
||||
<div class="image-grid mt-3">
|
||||
${memo.image_urls.slice(0, 4).map(url => `
|
||||
<div class="image-item">
|
||||
<img src="${url}" alt="첨부 이미지" onclick="showImageModal('${url}')">
|
||||
</div>
|
||||
`).join('')}
|
||||
${memo.image_urls.length > 4 ? `
|
||||
<div class="image-item flex items-center justify-center" style="background: var(--parchment); border: 2px dashed var(--sepia);">
|
||||
<span class="text-xs" style="color: var(--sepia);">+${memo.image_urls.length - 4}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 빈 상태 표시
|
||||
function showEmptyState() {
|
||||
document.getElementById('memoList').innerHTML = '';
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 변환 모달 열기
|
||||
function openConversionModal(memoId, type) {
|
||||
currentMemoId = memoId;
|
||||
currentConversionType = type;
|
||||
|
||||
const modalIcon = document.getElementById('modalIcon');
|
||||
const modalText = document.getElementById('modalText');
|
||||
const todoDateSection = document.getElementById('todoDateSection');
|
||||
const boardTitleSection = document.getElementById('boardTitleSection');
|
||||
|
||||
if (type === 'todo') {
|
||||
modalIcon.className = 'fas fa-tasks mr-2';
|
||||
modalText.textContent = 'Todo로 변환';
|
||||
todoDateSection.classList.remove('hidden');
|
||||
boardTitleSection.classList.add('hidden');
|
||||
|
||||
// 기본값을 오늘 날짜로 설정
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('todoStartDate').value = today;
|
||||
} else if (type === 'board') {
|
||||
modalIcon.className = 'fas fa-clipboard mr-2';
|
||||
modalText.textContent = '보드로 변환';
|
||||
todoDateSection.classList.add('hidden');
|
||||
boardTitleSection.classList.remove('hidden');
|
||||
|
||||
// 기존 메모 내용을 기본 제목으로 설정
|
||||
const memo = document.querySelector(`[onclick*="${memoId}"]`);
|
||||
if (memo) {
|
||||
const h3Element = memo.querySelector('h3');
|
||||
const pElement = memo.querySelector('p');
|
||||
let description = '';
|
||||
|
||||
if (h3Element) {
|
||||
description = h3Element.textContent.trim();
|
||||
} else if (pElement) {
|
||||
description = pElement.textContent.trim();
|
||||
// "내용 없음"이면 빈 문자열로 설정
|
||||
if (description === '내용 없음') {
|
||||
description = '';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('boardTitle').value = description;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('conversionModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 변환 모달 닫기
|
||||
function closeConversionModal() {
|
||||
currentMemoId = null;
|
||||
currentConversionType = null;
|
||||
document.getElementById('conversionModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 변환 확인
|
||||
async function confirmConversion() {
|
||||
if (!currentMemoId || !currentConversionType) return;
|
||||
|
||||
try {
|
||||
if (currentConversionType === 'todo') {
|
||||
const startDate = document.getElementById('todoStartDate').value;
|
||||
if (!startDate) {
|
||||
alert('시작 날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 메모를 Todo로 변환
|
||||
await TodoAPI.updateTodo(currentMemoId, {
|
||||
category: 'todo',
|
||||
start_date: startDate,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
alert('Todo로 변환되었습니다!');
|
||||
} else if (currentConversionType === 'board') {
|
||||
const boardTitle = document.getElementById('boardTitle').value.trim();
|
||||
if (!boardTitle) {
|
||||
alert('보드 제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 메모를 보드로 변환 (헤더로 설정)
|
||||
await TodoAPI.updateTodo(currentMemoId, {
|
||||
category: 'board',
|
||||
title: boardTitle,
|
||||
board_id: currentMemoId, // 자기 자신을 board_id로 설정
|
||||
is_board_header: true,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
alert('보드로 변환되었습니다!');
|
||||
}
|
||||
|
||||
closeConversionModal();
|
||||
loadMemos(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error('변환 실패:', error);
|
||||
alert('변환에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 시간 경과 표시
|
||||
function getTimeAgo(date) {
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return '방금 전';
|
||||
if (diffMins < 60) return `${diffMins}분 전`;
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
if (diffDays < 7) return `${diffDays}일 전`;
|
||||
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// 이미지 모달 표시 (간단한 구현)
|
||||
function showImageModal(imageUrl) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 date-modal flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="max-w-4xl max-h-4xl p-4">
|
||||
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg">
|
||||
</div>
|
||||
`;
|
||||
modal.onclick = () => modal.remove();
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 편집 모달 열기
|
||||
async function openEditModal(memoId) {
|
||||
try {
|
||||
// 메모 정보 가져오기
|
||||
const memo = await TodoAPI.getTodoById(memoId);
|
||||
currentEditMemo = memo;
|
||||
|
||||
// 폼 채우기
|
||||
document.getElementById('editDescription').value = memo.description || '';
|
||||
|
||||
// 기존 이미지 처리
|
||||
existingImages = memo.image_urls || [];
|
||||
if (existingImages.length > 0) {
|
||||
document.getElementById('existingImages').classList.remove('hidden');
|
||||
renderExistingImages();
|
||||
} else {
|
||||
document.getElementById('existingImages').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 새 이미지 초기화
|
||||
newEditImages = [];
|
||||
document.getElementById('newImagePreview').classList.add('hidden');
|
||||
|
||||
// 모바일/데스크톱 구분
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
if (isMobile) {
|
||||
document.querySelector('.desktop-upload').classList.add('hidden');
|
||||
document.querySelector('.mobile-upload').classList.remove('hidden');
|
||||
} else {
|
||||
document.querySelector('.desktop-upload').classList.remove('hidden');
|
||||
document.querySelector('.mobile-upload').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 파일 입력 이벤트 리스너
|
||||
const fileInput = document.getElementById('editImageInput');
|
||||
fileInput.onchange = handleEditImageSelect;
|
||||
|
||||
// 편집 폼 이벤트 리스너
|
||||
const editForm = document.getElementById('editForm');
|
||||
editForm.onsubmit = handleEditSubmit;
|
||||
|
||||
document.getElementById('editModal').classList.remove('hidden');
|
||||
} catch (error) {
|
||||
console.error('메모 로드 실패:', error);
|
||||
alert('메모를 불러올 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 편집 모달 닫기
|
||||
function closeEditModal() {
|
||||
currentEditMemo = null;
|
||||
newEditImages = [];
|
||||
existingImages = [];
|
||||
document.getElementById('editModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 기존 이미지 렌더링
|
||||
function renderExistingImages() {
|
||||
const grid = document.getElementById('existingImageGrid');
|
||||
grid.innerHTML = existingImages.map((url, index) => `
|
||||
<div class="relative">
|
||||
<img src="${url}" alt="기존 이미지" class="w-full h-24 object-cover rounded-lg">
|
||||
<button type="button" onclick="removeExistingImage(${index})"
|
||||
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 기존 이미지 제거
|
||||
function removeExistingImage(index) {
|
||||
existingImages.splice(index, 1);
|
||||
if (existingImages.length === 0) {
|
||||
document.getElementById('existingImages').classList.add('hidden');
|
||||
} else {
|
||||
renderExistingImages();
|
||||
}
|
||||
}
|
||||
|
||||
// 새 이미지 선택 처리
|
||||
async function handleEditImageSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
const totalImages = existingImages.length + newEditImages.length + files.length;
|
||||
|
||||
if (totalImages > 5) {
|
||||
alert('최대 5장까지만 업로드할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// 이미지 압축
|
||||
const compressedFile = await compressImageSimple(file, 0.7, 1920);
|
||||
const base64 = await convertFileToBase64(compressedFile);
|
||||
|
||||
newEditImages.push({
|
||||
file: compressedFile,
|
||||
preview: base64
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('이미지 처리 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderNewImages();
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
// 새 이미지 렌더링
|
||||
function renderNewImages() {
|
||||
if (newEditImages.length === 0) {
|
||||
document.getElementById('newImagePreview').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('newImagePreview').classList.remove('hidden');
|
||||
const grid = document.getElementById('newImageGrid');
|
||||
grid.innerHTML = newEditImages.map((img, index) => `
|
||||
<div class="relative">
|
||||
<img src="${img.preview}" alt="새 이미지" class="w-full h-24 object-cover rounded-lg">
|
||||
<button type="button" onclick="removeNewImage(${index})"
|
||||
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full text-xs hover:bg-red-600">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 새 이미지 제거
|
||||
function removeNewImage(index) {
|
||||
newEditImages.splice(index, 1);
|
||||
renderNewImages();
|
||||
}
|
||||
|
||||
// 모바일 카메라 캡처
|
||||
async function captureEditImage() {
|
||||
try {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.capture = 'environment';
|
||||
input.onchange = handleEditImageSelect;
|
||||
input.click();
|
||||
} catch (error) {
|
||||
console.error('카메라 접근 실패:', error);
|
||||
alert('카메라에 접근할 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 편집 폼 제출
|
||||
async function handleEditSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const description = document.getElementById('editDescription').value.trim();
|
||||
|
||||
if (!description) {
|
||||
alert('내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 새 이미지 업로드
|
||||
const newImageUrls = [];
|
||||
for (const img of newEditImages) {
|
||||
try {
|
||||
const uploadResult = await TodoAPI.uploadImage(img.file);
|
||||
newImageUrls.push(uploadResult.file_url);
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 이미지 URL 합치기
|
||||
const allImageUrls = [...existingImages, ...newImageUrls];
|
||||
|
||||
// 메모 업데이트
|
||||
const updateData = {
|
||||
description: description,
|
||||
image_urls: allImageUrls
|
||||
};
|
||||
|
||||
await TodoAPI.updateTodo(currentEditMemo.id, updateData);
|
||||
|
||||
alert('메모가 성공적으로 수정되었습니다!');
|
||||
closeEditModal();
|
||||
loadMemos(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error('메모 수정 실패:', error);
|
||||
alert('메모 수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 이미지 압축 함수 (upload.html에서 복사)
|
||||
async function compressImageSimple(file, quality = 0.7, maxWidth = 1920) {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const ratio = Math.min(maxWidth / img.width, maxWidth / img.height);
|
||||
canvas.width = img.width * ratio;
|
||||
canvas.height = img.height * ratio;
|
||||
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob(resolve, 'image/jpeg', quality);
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Base64 변환 함수
|
||||
function convertFileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 함수 등록
|
||||
window.openConversionModal = openConversionModal;
|
||||
window.closeConversionModal = closeConversionModal;
|
||||
window.confirmConversion = confirmConversion;
|
||||
window.showImageModal = showImageModal;
|
||||
window.openEditModal = openEditModal;
|
||||
window.closeEditModal = closeEditModal;
|
||||
window.removeExistingImage = removeExistingImage;
|
||||
window.removeNewImage = removeNewImage;
|
||||
window.captureEditImage = captureEditImage;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -33,57 +33,77 @@
|
||||
<!-- 외부 라이브러리 -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6; /* 하늘색 */
|
||||
--primary-dark: #2563eb; /* 진한 하늘색 */
|
||||
--success: #10b981; /* 초록색 (유지) */
|
||||
--warning: #f59e0b; /* 주황색 */
|
||||
--danger: #ef4444; /* 빨간색 (유지) */
|
||||
--gray-50: #f9fafb; /* 연한 회색 */
|
||||
--gray-100: #f3f4f6; /* 회색 */
|
||||
--gray-200: #e5e7eb; /* 중간 회색 */
|
||||
--gray-300: #d1d5db; /* 진한 회색 */
|
||||
--parchment: #f7f3e9;
|
||||
--parchment-dark: #f0ead6;
|
||||
--ink: #2c1810;
|
||||
--ink-light: #5d4e37;
|
||||
--sepia: #8b7355;
|
||||
--gold: #d4af37;
|
||||
--shadow: rgba(139, 115, 85, 0.2);
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gray-50);
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
background: linear-gradient(to bottom, var(--parchment) 0%, #e0d8c7 100%);
|
||||
color: var(--ink);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.parchment-container {
|
||||
background-color: var(--parchment);
|
||||
border: 1px solid var(--sepia);
|
||||
box-shadow: 3px 3px 8px var(--shadow);
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.parchment-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -3px; left: -3px; right: -3px; bottom: -3px;
|
||||
border: 1px dashed var(--gold);
|
||||
z-index: -1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
background-color: var(--gold);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--gold);
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning);
|
||||
background-color: var(--sepia);
|
||||
border-color: var(--sepia);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: #d97706;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: 1px solid var(--gray-300);
|
||||
border: 1px solid var(--sepia);
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border-color: var(--primary);
|
||||
border-color: var(--gold);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
|
||||
border-bottom: 3px solid var(--gold);
|
||||
box-shadow: 0 2px 10px var(--shadow);
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
@@ -107,53 +127,102 @@
|
||||
<body>
|
||||
<!-- 로그인 화면 -->
|
||||
<div id="loginScreen" class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
|
||||
<div class="text-center mb-6">
|
||||
<i class="fas fa-tasks text-4xl text-blue-500 mb-4"></i>
|
||||
<h1 class="text-2xl font-bold text-gray-800">Todo Project</h1>
|
||||
<p class="text-gray-500 text-sm">간결한 할일 관리</p>
|
||||
<div class="parchment-container p-8 w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<i class="fas fa-feather-alt text-5xl mb-4" style="color: var(--gold);"></i>
|
||||
<h1 class="text-3xl font-semibold mb-2" style="color: var(--ink);">Todo Project</h1>
|
||||
<p class="text-sm" style="color: var(--ink-light);">메모 중심의 간결한 할일 관리</p>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" class="space-y-4">
|
||||
<form id="loginForm" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">사용자명</label>
|
||||
<input type="text" id="username" class="input-field w-full px-3 py-2 rounded-lg" placeholder="사용자명을 입력하세요" required>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">사용자명</label>
|
||||
<input type="text" id="username" class="input-field w-full px-4 py-3 rounded-lg" placeholder="사용자명을 입력하세요" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||
<input type="password" id="password" class="input-field w-full px-3 py-2 rounded-lg" placeholder="비밀번호를 입력하세요" required>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">비밀번호</label>
|
||||
<input type="password" id="password" class="input-field w-full px-4 py-3 rounded-lg" placeholder="비밀번호를 입력하세요" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full py-2 px-4 rounded-lg font-medium">
|
||||
로그인
|
||||
<button type="submit" class="btn-primary w-full py-3 px-4 rounded-lg font-medium text-lg">
|
||||
<i class="fas fa-sign-in-alt mr-2"></i>로그인
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
<button onclick="testLogin()" class="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors text-sm">
|
||||
🚀 테스트 로그인 (관리자)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-gray-500 text-center">
|
||||
<p>관리자: hyungi / admin123</p>
|
||||
</div>
|
||||
|
||||
<!-- 이미 로그인된 상태 표시 -->
|
||||
<div id="alreadyLoggedIn" class="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg hidden">
|
||||
<div id="alreadyLoggedIn" class="mt-6 p-4 rounded-lg hidden" style="background-color: #f0fdf4; border: 1px solid #bbf7d0;">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-check-circle text-green-500 text-xl mb-2"></i>
|
||||
<p class="text-green-800 font-medium mb-2">이미 로그인되어 있습니다!</p>
|
||||
<button onclick="window.location.href='dashboard.html'"
|
||||
class="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors">
|
||||
대시보드로 이동
|
||||
<i class="fas fa-check-circle text-xl mb-3" style="color: var(--success);"></i>
|
||||
<p class="font-medium mb-3" style="color: #166534;">이미 로그인되어 있습니다!</p>
|
||||
<button onclick="window.location.href='upload.html'"
|
||||
class="w-full py-2 px-4 rounded-lg font-medium transition-all"
|
||||
style="background-color: var(--success); color: white; border: 1px solid var(--success);"
|
||||
onmouseover="this.style.backgroundColor='#059669'"
|
||||
onmouseout="this.style.backgroundColor='var(--success)'">
|
||||
<i class="fas fa-feather-alt mr-2"></i>메모 작성하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 초기 설정 화면 -->
|
||||
<div id="setupScreen" class="hidden min-h-screen flex items-center justify-center p-4">
|
||||
<div class="parchment-container p-8 w-full max-w-lg">
|
||||
<div class="text-center mb-8">
|
||||
<i class="fas fa-cog text-5xl mb-4" style="color: var(--gold);"></i>
|
||||
<h1 class="text-3xl font-semibold mb-2" style="color: var(--ink);">시스템 초기 설정</h1>
|
||||
<p class="text-sm" style="color: var(--ink-light);">관리자 계정을 설정하여 Todo Project를 시작하세요</p>
|
||||
</div>
|
||||
|
||||
<form id="setupForm" class="space-y-5">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">관리자 사용자명</label>
|
||||
<input type="text" id="setupUsername" class="input-field w-full px-4 py-3 rounded-lg"
|
||||
placeholder="관리자 사용자명 (3자 이상)" required minlength="3">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">관리자 이메일</label>
|
||||
<input type="email" id="setupEmail" class="input-field w-full px-4 py-3 rounded-lg"
|
||||
placeholder="admin@example.com" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">관리자 비밀번호</label>
|
||||
<input type="password" id="setupPassword" class="input-field w-full px-4 py-3 rounded-lg"
|
||||
placeholder="비밀번호 (6자 이상)" required minlength="6">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">관리자 이름</label>
|
||||
<input type="text" id="setupFullName" class="input-field w-full px-4 py-3 rounded-lg"
|
||||
placeholder="Administrator" value="Administrator">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full py-3 px-4 rounded-lg font-medium text-lg">
|
||||
<i class="fas fa-rocket mr-2"></i>시스템 초기화
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 p-4 rounded-lg" style="background-color: #fef3c7; border: 1px solid #f59e0b;">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-exclamation-triangle mr-3 mt-1" style="color: #d97706;"></i>
|
||||
<div class="text-sm" style="color: #92400e;">
|
||||
<p class="font-medium mb-1">주의사항</p>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li>이 설정은 최초 1회만 가능합니다</li>
|
||||
<li>관리자 계정 정보를 안전하게 보관하세요</li>
|
||||
<li>설정 완료 후 즉시 로그인됩니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 애플리케이션 -->
|
||||
<div id="mainApp" class="hidden min-h-screen">
|
||||
<!-- 헤더 -->
|
||||
@@ -293,19 +362,54 @@
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script>
|
||||
// 토큰 상태 확인
|
||||
console.log('=== 토큰 상태 확인 ===');
|
||||
const existingToken = localStorage.getItem('authToken');
|
||||
const existingUser = localStorage.getItem('currentUser');
|
||||
|
||||
console.log('기존 토큰 존재:', existingToken ? '있음' : '없음');
|
||||
console.log('기존 사용자 정보:', existingUser);
|
||||
|
||||
// 토큰이 있으면 대시보드로 리다이렉트 (무한 루프 방지)
|
||||
if (existingToken && existingUser) {
|
||||
console.log('유효한 토큰이 있습니다. 대시보드로 이동합니다.');
|
||||
window.location.href = 'dashboard.html';
|
||||
// 초기 설정 상태 확인
|
||||
async function checkSetupStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/setup/status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.is_setup_required) {
|
||||
// 초기 설정이 필요한 경우
|
||||
document.getElementById('loginScreen').classList.add('hidden');
|
||||
document.getElementById('setupScreen').classList.remove('hidden');
|
||||
return false;
|
||||
} else {
|
||||
// 이미 설정된 경우 로그인 화면 표시
|
||||
document.getElementById('setupScreen').classList.add('hidden');
|
||||
document.getElementById('loginScreen').classList.remove('hidden');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('설정 상태 확인 실패:', error);
|
||||
// 오류 시 로그인 화면 표시
|
||||
document.getElementById('setupScreen').classList.add('hidden');
|
||||
document.getElementById('loginScreen').classList.remove('hidden');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 상태 확인
|
||||
async function checkAuthStatus() {
|
||||
console.log('=== 토큰 상태 확인 ===');
|
||||
const existingToken = localStorage.getItem('authToken');
|
||||
const existingUser = localStorage.getItem('currentUser');
|
||||
|
||||
console.log('기존 토큰 존재:', existingToken ? '있음' : '없음');
|
||||
console.log('기존 사용자 정보:', existingUser);
|
||||
|
||||
// 토큰이 있으면 대시보드로 리다이렉트
|
||||
if (existingToken && existingUser) {
|
||||
console.log('유효한 토큰이 있습니다. 대시보드로 이동합니다.');
|
||||
window.location.href = 'upload.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 없으면 설정 상태 확인
|
||||
await checkSetupStatus();
|
||||
}
|
||||
|
||||
// 페이지 로드 시 상태 확인
|
||||
checkAuthStatus();
|
||||
</script>
|
||||
|
||||
<script src="static/js/api.js?v=20250921110800"></script>
|
||||
@@ -320,6 +424,58 @@
|
||||
console.log('AuthAPI 존재:', typeof AuthAPI !== 'undefined');
|
||||
console.log('window.currentUser:', window.currentUser);
|
||||
|
||||
// 초기 설정 폼 이벤트 리스너
|
||||
const setupForm = document.getElementById('setupForm');
|
||||
if (setupForm) {
|
||||
setupForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const username = document.getElementById('setupUsername').value;
|
||||
const email = document.getElementById('setupEmail').value;
|
||||
const password = document.getElementById('setupPassword').value;
|
||||
const fullName = document.getElementById('setupFullName').value;
|
||||
|
||||
if (!username || !email || !password) {
|
||||
alert('모든 필수 필드를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('시스템 초기화 시도...');
|
||||
const response = await fetch('/api/setup/initialize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
admin_username: username,
|
||||
admin_email: email,
|
||||
admin_password: password,
|
||||
admin_full_name: fullName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.detail || '초기화 실패');
|
||||
}
|
||||
|
||||
console.log('초기화 성공:', result);
|
||||
alert('시스템이 성공적으로 초기화되었습니다!\\n생성된 계정으로 자동 로그인합니다.');
|
||||
|
||||
// 자동 로그인
|
||||
const loginResult = await AuthAPI.login(username, password);
|
||||
console.log('자동 로그인 성공:', loginResult);
|
||||
window.location.href = 'upload.html';
|
||||
|
||||
} catch (error) {
|
||||
console.error('초기화 실패:', error);
|
||||
alert('초기화 실패: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 로그인 폼 이벤트 리스너 수동 추가 (백업)
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
if (loginForm && !loginForm.hasAttribute('data-listener-added')) {
|
||||
@@ -340,7 +496,7 @@
|
||||
console.log('로그인 시도:', username);
|
||||
const result = await AuthAPI.login(username, password);
|
||||
console.log('로그인 성공:', result);
|
||||
window.location.href = 'dashboard.html';
|
||||
window.location.href = 'upload.html';
|
||||
} catch (error) {
|
||||
console.error('로그인 실패:', error);
|
||||
alert('로그인 실패: ' + error.message);
|
||||
@@ -349,30 +505,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 테스트 로그인 함수
|
||||
async function testLogin() {
|
||||
try {
|
||||
console.log('테스트 로그인 시작...');
|
||||
const result = await AuthAPI.login('hyungi', 'admin123');
|
||||
console.log('로그인 성공:', result);
|
||||
|
||||
// 토큰 확인
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('저장된 토큰:', token ? '있음' : '없음');
|
||||
|
||||
// 사용자 정보 확인
|
||||
const user = localStorage.getItem('currentUser');
|
||||
console.log('저장된 사용자 정보:', user);
|
||||
|
||||
// 대시보드로 이동
|
||||
setTimeout(() => {
|
||||
window.location.href = 'dashboard.html';
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('테스트 로그인 실패:', error);
|
||||
alert('로그인 실패: ' + error.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
60
frontend/nginx.conf
Normal file
60
frontend/nginx.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 파일 업로드 크기 제한 (50MB)
|
||||
client_max_body_size 50M;
|
||||
|
||||
# 프론트엔드 파일 서빙
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 프록시 설정
|
||||
location /api/ {
|
||||
proxy_pass http://backend:9000/api/;
|
||||
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;
|
||||
|
||||
# CORS 헤더 추가
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
|
||||
# OPTIONS 요청 처리
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Content-Length 0;
|
||||
add_header Content-Type text/plain;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# 업로드된 이미지 서빙 (백엔드 프록시)
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:9000/uploads/;
|
||||
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;
|
||||
|
||||
# 캐시 설정 (이미지 파일용)
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# API 루트 경로 프록시
|
||||
location = /api {
|
||||
proxy_pass http://backend:9000/;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
* API 통신 유틸리티
|
||||
*/
|
||||
|
||||
const API_BASE_URL = 'http://localhost:9000/api';
|
||||
// 환경에 따른 API URL 설정
|
||||
const API_BASE_URL = window.location.hostname === 'localhost'
|
||||
? 'http://localhost:9000/api' // 로컬 개발 환경
|
||||
: `${window.location.protocol}//${window.location.hostname}:9000/api`; // 시놀로지 배포 환경
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
|
||||
@@ -90,7 +90,7 @@ async function handleLogin(event) {
|
||||
window.currentUser = response.user;
|
||||
|
||||
// 대시보드로 리다이렉트
|
||||
window.location.href = 'dashboard.html';
|
||||
window.location.href = 'inbox.html';
|
||||
|
||||
} catch (error) {
|
||||
console.error('로그인 실패:', error);
|
||||
@@ -169,7 +169,7 @@ function showMainApp() {
|
||||
|
||||
// index.html에서는 대시보드로 리다이렉트
|
||||
if (!mainApp && loginScreen) {
|
||||
window.location.href = 'dashboard.html';
|
||||
window.location.href = 'inbox.html';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -396,7 +396,7 @@ function goToPage(pageType) {
|
||||
|
||||
// 대시보드로 이동
|
||||
function goToDashboard() {
|
||||
window.location.href = 'dashboard.html';
|
||||
window.location.href = 'upload.html';
|
||||
}
|
||||
|
||||
// 분류 센터로 이동
|
||||
|
||||
643
frontend/todo-list.html
Normal file
643
frontend/todo-list.html
Normal file
@@ -0,0 +1,643 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Todo 목록 - Todo Project</title>
|
||||
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--parchment: #f7f3e9;
|
||||
--parchment-dark: #f0ead6;
|
||||
--ink: #2c1810;
|
||||
--ink-light: #5d4e37;
|
||||
--sepia: #8b7355;
|
||||
--gold: #d4af37;
|
||||
--shadow: rgba(139, 115, 85, 0.2);
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
background: linear-gradient(135deg, #f7f3e9 0%, #f0ead6 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.parchment-container {
|
||||
background: var(--parchment);
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, rgba(139, 115, 85, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(139, 115, 85, 0.05) 0%, transparent 50%);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 8px 32px var(--shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.parchment-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, var(--gold), var(--sepia), var(--gold));
|
||||
border-radius: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.header-vintage {
|
||||
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
|
||||
border-bottom: 3px solid var(--gold);
|
||||
box-shadow: 0 2px 10px var(--shadow);
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
background: var(--parchment-dark);
|
||||
border: 1px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.todo-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px var(--shadow);
|
||||
}
|
||||
|
||||
.todo-item.overdue {
|
||||
border-left: 4px solid var(--danger);
|
||||
background: linear-gradient(135deg, var(--parchment-dark), #fef2f2);
|
||||
}
|
||||
|
||||
.todo-item.today {
|
||||
border-left: 4px solid var(--warning);
|
||||
background: linear-gradient(135deg, var(--parchment-dark), #fffbeb);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
|
||||
color: var(--parchment);
|
||||
border: 2px solid var(--gold);
|
||||
border-radius: 20px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.action-button.complete {
|
||||
background: linear-gradient(135deg, var(--success), #16a34a);
|
||||
}
|
||||
|
||||
.action-button.delay {
|
||||
background: linear-gradient(135deg, var(--warning), #d97706);
|
||||
}
|
||||
|
||||
.action-button.extend {
|
||||
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
|
||||
gap: 0.5rem;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.image-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--sepia);
|
||||
}
|
||||
|
||||
.image-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.date-modal {
|
||||
background: rgba(44, 24, 16, 0.8);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.date-input {
|
||||
background: var(--parchment);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--warning);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="header-vintage">
|
||||
<div class="max-w-6xl mx-auto px-4 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-2xl font-semibold" style="color: var(--ink);">
|
||||
<i class="fas fa-tasks mr-3" style="color: var(--gold);"></i>
|
||||
Todo 목록
|
||||
</h1>
|
||||
<span id="todoCount" class="ml-4 px-3 py-1 bg-gold text-white rounded-full text-sm font-medium"></span>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="upload.html" class="action-button">
|
||||
<i class="fas fa-feather-alt mr-2"></i>메모
|
||||
</a>
|
||||
<a href="inbox.html" class="action-button">
|
||||
<i class="fas fa-inbox mr-2"></i>수신함
|
||||
</a>
|
||||
<a href="todo-list.html" class="action-button" style="background: var(--gold); border-color: var(--gold); color: var(--ink);">
|
||||
<i class="fas fa-tasks mr-2"></i>Todo 목록
|
||||
</a>
|
||||
<a href="board.html" class="action-button">
|
||||
<i class="fas fa-clipboard mr-2"></i>보드
|
||||
</a>
|
||||
<a href="archive.html" class="action-button">
|
||||
<i class="fas fa-archive mr-2"></i>아카이브
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-6xl mx-auto px-4 py-8">
|
||||
<!-- 오늘 날짜 표시 -->
|
||||
<div class="parchment-container p-4 mb-6">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<h2 class="text-xl font-medium" style="color: var(--ink);">
|
||||
<i class="fas fa-calendar-day mr-3" style="color: var(--gold);"></i>
|
||||
<span id="todayDate"></span>
|
||||
</h2>
|
||||
<p class="text-sm mt-1" style="color: var(--ink-light);">오늘 해야 할 일들</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Todo 목록 -->
|
||||
<div class="parchment-container p-6">
|
||||
<div id="todoList">
|
||||
<!-- Todo 목록이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="hidden text-center py-12">
|
||||
<i class="fas fa-clipboard-list text-6xl mb-4" style="color: var(--sepia); opacity: 0.5;"></i>
|
||||
<h3 class="text-xl font-medium mb-2" style="color: var(--ink-light);">진행할 Todo가 없습니다</h3>
|
||||
<p class="text-sm mb-6" style="color: var(--sepia);">수신함에서 새로운 Todo를 추가해보세요</p>
|
||||
<a href="inbox.html" class="action-button">
|
||||
<i class="fas fa-inbox mr-2"></i>수신함으로 이동
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 날짜 선택 모달 -->
|
||||
<div id="dateModal" class="hidden fixed inset-0 date-modal flex items-center justify-center z-50">
|
||||
<div class="parchment-container p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-semibold mb-4 text-center" style="color: var(--ink);">
|
||||
<i class="fas fa-calendar-alt mr-2" style="color: var(--gold);"></i>
|
||||
새로운 날짜 선택
|
||||
</h3>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--ink-light);">지연할 날짜</label>
|
||||
<input type="date" id="newDueDate" class="date-input w-full">
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="confirmDateChange()" class="action-button flex-1">
|
||||
<i class="fas fa-check mr-2"></i>확인
|
||||
</button>
|
||||
<button onclick="closeDateModal()" class="action-button flex-1" style="background: var(--sepia);">
|
||||
<i class="fas fa-times mr-2"></i>취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script>
|
||||
let allTodos = [];
|
||||
let currentTodoId = null;
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 인증 확인
|
||||
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
displayTodayDate();
|
||||
loadTodos();
|
||||
});
|
||||
|
||||
// Todo 목록 로드
|
||||
async function loadTodos() {
|
||||
try {
|
||||
const todos = await TodoAPI.getTodos(null, 'todo');
|
||||
|
||||
// 서울 시간 기준 오늘 날짜 계산
|
||||
const seoulTime = new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"});
|
||||
const today = new Date(seoulTime);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
allTodos = todos.filter(todo => {
|
||||
if (todo.status === 'completed') return false;
|
||||
|
||||
// 시작 날짜가 오늘 이전이거나 오늘인 Todo들 표시 (해야 할 시점이 된 것들)
|
||||
const startDate = new Date(todo.start_date);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return startDate.getTime() <= today.getTime();
|
||||
});
|
||||
|
||||
filterTodos();
|
||||
} catch (error) {
|
||||
console.error('Todo 로드 실패:', error);
|
||||
showEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 정렬 및 렌더링
|
||||
function filterTodos() {
|
||||
let sortedTodos = [...allTodos];
|
||||
|
||||
// 상태별 우선순위 정렬 (오늘 시작 > 진행 중) + 생성일 최신순
|
||||
sortedTodos.sort((a, b) => {
|
||||
const seoulTime = new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"});
|
||||
const today = new Date(seoulTime);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const startDateA = new Date(a.start_date);
|
||||
const startDateB = new Date(b.start_date);
|
||||
startDateA.setHours(0, 0, 0, 0);
|
||||
startDateB.setHours(0, 0, 0, 0);
|
||||
|
||||
// 오늘 시작하는 것을 우선으로
|
||||
const isATodayStart = startDateA.getTime() === today.getTime();
|
||||
const isBTodayStart = startDateB.getTime() === today.getTime();
|
||||
|
||||
if (isATodayStart && !isBTodayStart) return -1;
|
||||
if (!isATodayStart && isBTodayStart) return 1;
|
||||
|
||||
// 같은 상태면 생성일 최신순
|
||||
const createdA = new Date(a.created_at);
|
||||
const createdB = new Date(b.created_at);
|
||||
return createdB - createdA;
|
||||
});
|
||||
|
||||
renderTodos(sortedTodos);
|
||||
}
|
||||
|
||||
// 오늘 날짜 표시
|
||||
function displayTodayDate() {
|
||||
const seoulTime = new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"});
|
||||
const today = new Date(seoulTime);
|
||||
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
timeZone: 'Asia/Seoul'
|
||||
};
|
||||
|
||||
const todayString = today.toLocaleDateString('ko-KR', options);
|
||||
document.getElementById('todayDate').textContent = todayString;
|
||||
}
|
||||
|
||||
// Todo 목록 렌더링
|
||||
function renderTodos(todos) {
|
||||
const todoList = document.getElementById('todoList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const todoCount = document.getElementById('todoCount');
|
||||
|
||||
todoCount.textContent = `${todos.length}개`;
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
// 서울 시간 기준 오늘 날짜 계산
|
||||
const seoulTime = new Date().toLocaleString("en-US", {timeZone: "Asia/Seoul"});
|
||||
const today = new Date(seoulTime);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
todoList.innerHTML = todos.map(todo => {
|
||||
const hasImages = todo.image_urls && todo.image_urls.length > 0;
|
||||
const createdAt = new Date(todo.created_at);
|
||||
const timeAgo = getTimeAgo(createdAt);
|
||||
|
||||
// 시작 날짜 상태 계산
|
||||
const startDate = new Date(todo.start_date);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
let statusClass = '';
|
||||
let statusText = '';
|
||||
let statusIcon = '';
|
||||
|
||||
if (startDate.getTime() < today.getTime()) {
|
||||
// 시작 날짜가 지났지만 아직 완료하지 않은 경우
|
||||
statusClass = 'overdue';
|
||||
statusText = '진행 중';
|
||||
statusIcon = 'fas fa-play';
|
||||
} else if (startDate.getTime() === today.getTime()) {
|
||||
// 오늘 시작하는 Todo
|
||||
statusClass = 'today';
|
||||
statusText = '오늘 시작';
|
||||
statusIcon = 'fas fa-clock';
|
||||
}
|
||||
|
||||
const formattedDate = startDate.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="todo-item ${statusClass}">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center mb-2">
|
||||
<span class="priority-badge priority-medium">
|
||||
<i class="${statusIcon} mr-1"></i>
|
||||
${statusText}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-base mb-2 font-medium" style="color: var(--ink);">
|
||||
${todo.description || '내용 없음'}
|
||||
</p>
|
||||
<div class="flex items-center text-xs" style="color: var(--sepia);">
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
<span>시작일: ${formattedDate}</span>
|
||||
${hasImages ? `<i class="fas fa-images ml-3 mr-1"></i><span>${todo.image_urls.length}장</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${hasImages ? `
|
||||
<div class="image-grid mb-4">
|
||||
${todo.image_urls.slice(0, 4).map(url => `
|
||||
<div class="image-item">
|
||||
<img src="${url}" alt="첨부 이미지" onclick="showImageModal('${url}')">
|
||||
</div>
|
||||
`).join('')}
|
||||
${todo.image_urls.length > 4 ? `
|
||||
<div class="image-item flex items-center justify-center" style="background: var(--parchment); border: 2px dashed var(--sepia);">
|
||||
<span class="text-xs" style="color: var(--sepia);">+${todo.image_urls.length - 4}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button onclick="completeTodo('${todo.id}')" class="action-button complete">
|
||||
<i class="fas fa-check mr-1"></i>완료
|
||||
</button>
|
||||
<button onclick="extendTodo('${todo.id}', 3)" class="action-button extend">
|
||||
<i class="fas fa-plus mr-1"></i>+3일
|
||||
</button>
|
||||
<button onclick="extendTodo('${todo.id}', 5)" class="action-button extend">
|
||||
<i class="fas fa-plus mr-1"></i>+5일
|
||||
</button>
|
||||
<button onclick="openDateModal('${todo.id}')" class="action-button delay">
|
||||
<i class="fas fa-calendar-alt mr-1"></i>지연
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 빈 상태 표시
|
||||
function showEmptyState() {
|
||||
document.getElementById('todoList').innerHTML = '';
|
||||
document.getElementById('emptyState').classList.remove('hidden');
|
||||
document.getElementById('todoCount').textContent = '0개';
|
||||
}
|
||||
|
||||
// Todo 완료 처리
|
||||
async function completeTodo(todoId) {
|
||||
try {
|
||||
await TodoAPI.updateTodo(todoId, {
|
||||
status: 'completed',
|
||||
completed_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 완료된 Todo를 목록에서 제거
|
||||
allTodos = allTodos.filter(todo => todo.id !== todoId);
|
||||
filterTodos();
|
||||
|
||||
// 성공 메시지
|
||||
showToast('Todo가 완료되었습니다!', 'success');
|
||||
} catch (error) {
|
||||
console.error('Todo 완료 실패:', error);
|
||||
showToast('Todo 완료에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 기간 연장
|
||||
async function extendTodo(todoId, days) {
|
||||
try {
|
||||
const todo = allTodos.find(t => t.id === todoId);
|
||||
if (!todo) return;
|
||||
|
||||
const currentDate = new Date(todo.start_date);
|
||||
const newDate = new Date(currentDate.getTime() + (days * 24 * 60 * 60 * 1000));
|
||||
|
||||
await TodoAPI.updateTodo(todoId, {
|
||||
start_date: newDate.toISOString().split('T')[0]
|
||||
});
|
||||
|
||||
// 로컬 데이터 업데이트
|
||||
todo.start_date = newDate.toISOString().split('T')[0];
|
||||
filterTodos();
|
||||
|
||||
showToast(`${days}일 연장되었습니다!`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Todo 연장 실패:', error);
|
||||
showToast('Todo 연장에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 선택 모달 열기
|
||||
function openDateModal(todoId) {
|
||||
currentTodoId = todoId;
|
||||
const todo = allTodos.find(t => t.id === todoId);
|
||||
if (todo) {
|
||||
// 현재 날짜를 기본값으로 설정
|
||||
const currentDate = new Date(todo.start_date);
|
||||
document.getElementById('newDueDate').value = currentDate.toISOString().split('T')[0];
|
||||
}
|
||||
document.getElementById('dateModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 날짜 선택 모달 닫기
|
||||
function closeDateModal() {
|
||||
currentTodoId = null;
|
||||
document.getElementById('dateModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 날짜 변경 확인
|
||||
async function confirmDateChange() {
|
||||
if (!currentTodoId) return;
|
||||
|
||||
const newDate = document.getElementById('newDueDate').value;
|
||||
if (!newDate) {
|
||||
alert('새로운 날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await TodoAPI.updateTodo(currentTodoId, {
|
||||
start_date: newDate
|
||||
});
|
||||
|
||||
// 로컬 데이터 업데이트
|
||||
const todo = allTodos.find(t => t.id === currentTodoId);
|
||||
if (todo) {
|
||||
todo.start_date = newDate;
|
||||
}
|
||||
|
||||
filterTodos();
|
||||
closeDateModal();
|
||||
showToast('날짜가 변경되었습니다!', 'success');
|
||||
} catch (error) {
|
||||
console.error('날짜 변경 실패:', error);
|
||||
showToast('날짜 변경에 실패했습니다.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white font-medium z-50 transform transition-all duration-300`;
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
toast.style.background = 'var(--success)';
|
||||
break;
|
||||
case 'error':
|
||||
toast.style.background = 'var(--danger)';
|
||||
break;
|
||||
default:
|
||||
toast.style.background = 'var(--sepia)';
|
||||
}
|
||||
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// 애니메이션
|
||||
setTimeout(() => toast.style.transform = 'translateX(0)', 100);
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 시간 경과 표시
|
||||
function getTimeAgo(date) {
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return '방금 전';
|
||||
if (diffMins < 60) return `${diffMins}분 전`;
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
if (diffDays < 7) return `${diffDays}일 전`;
|
||||
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// 이미지 모달 표시
|
||||
function showImageModal(imageUrl) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'fixed inset-0 date-modal flex items-center justify-center z-50';
|
||||
modal.innerHTML = `
|
||||
<div class="max-w-4xl max-h-4xl p-4">
|
||||
<img src="${imageUrl}" class="max-w-full max-h-full object-contain rounded-lg">
|
||||
</div>
|
||||
`;
|
||||
modal.onclick = () => modal.remove();
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// 전역 함수 등록
|
||||
window.filterTodos = filterTodos;
|
||||
window.completeTodo = completeTodo;
|
||||
window.extendTodo = extendTodo;
|
||||
window.openDateModal = openDateModal;
|
||||
window.closeDateModal = closeDateModal;
|
||||
window.confirmDateChange = confirmDateChange;
|
||||
window.showImageModal = showImageModal;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,353 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Todo - 시작 날짜가 있는 일들</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6; /* 하늘색 */
|
||||
--primary-dark: #2563eb; /* 진한 하늘색 */
|
||||
--success: #10b981; /* 초록색 */
|
||||
--warning: #f59e0b; /* 주황색 */
|
||||
--danger: #ef4444; /* 빨간색 */
|
||||
--gray-50: #f9fafb; /* 연한 회색 */
|
||||
--gray-100: #f3f4f6; /* 회색 */
|
||||
--gray-200: #e5e7eb; /* 중간 회색 */
|
||||
--gray-300: #d1d5db; /* 진한 회색 */
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.todo-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center">
|
||||
<button onclick="goBack()" class="mr-4 text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-arrow-left text-xl"></i>
|
||||
</button>
|
||||
<i class="fas fa-calendar-day text-2xl text-blue-500 mr-3"></i>
|
||||
<h1 class="text-xl font-semibold text-gray-800">Todo</h1>
|
||||
<span class="ml-3 text-sm text-gray-500">시작 날짜가 있는 일들</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<button onclick="goToDashboard()" class="text-blue-600 hover:text-blue-800 font-medium">
|
||||
<i class="fas fa-chart-line mr-1"></i>대시보드
|
||||
</button>
|
||||
<span class="text-sm text-gray-600" id="currentUser"></span>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 페이지 설명 -->
|
||||
<div class="bg-blue-50 rounded-xl p-6 mb-8">
|
||||
<div class="flex items-center mb-4">
|
||||
<i class="fas fa-calendar-day text-2xl text-blue-600 mr-3"></i>
|
||||
<h2 class="text-xl font-semibold text-blue-900">Todo 관리</h2>
|
||||
</div>
|
||||
<p class="text-blue-800 mb-4">
|
||||
시작 날짜가 정해진 일들을 관리합니다. 언제 시작할지 계획을 세우고 실행해보세요.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-blue-900 mb-1">📅 시작 예정</div>
|
||||
<div class="text-blue-700">아직 시작하지 않은 일들</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-blue-900 mb-1">🔥 진행 중</div>
|
||||
<div class="text-blue-700">현재 작업 중인 일들</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3">
|
||||
<div class="font-medium text-blue-900 mb-1">✅ 완료</div>
|
||||
<div class="text-blue-700">완료된 일들</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 및 정렬 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div class="flex space-x-1 bg-gray-100 rounded-lg p-1">
|
||||
<button onclick="filterTodos('all')" class="filter-tab active px-4 py-2 rounded text-sm font-medium">전체</button>
|
||||
<button onclick="filterTodos('scheduled')" class="filter-tab px-4 py-2 rounded text-sm font-medium">시작 예정</button>
|
||||
<button onclick="filterTodos('active')" class="filter-tab px-4 py-2 rounded text-sm font-medium">진행 중</button>
|
||||
<button onclick="filterTodos('completed')" class="filter-tab px-4 py-2 rounded text-sm font-medium">완료</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="text-sm text-gray-600">정렬:</label>
|
||||
<select id="sortBy" class="border border-gray-300 rounded-lg px-3 py-1 text-sm">
|
||||
<option value="start_date">시작일 순</option>
|
||||
<option value="created_at">등록일 순</option>
|
||||
<option value="priority">우선순위 순</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Todo 목록 -->
|
||||
<div class="bg-white rounded-xl shadow-sm">
|
||||
<div class="p-6 border-b">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
<i class="fas fa-list text-blue-500 mr-2"></i>Todo 목록
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div id="todoList" class="divide-y divide-gray-100">
|
||||
<!-- Todo 항목들이 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="p-12 text-center text-gray-500">
|
||||
<i class="fas fa-calendar-day text-4xl mb-4 opacity-50"></i>
|
||||
<p>아직 시작 날짜가 설정된 일이 없습니다.</p>
|
||||
<p class="text-sm">메인 페이지에서 항목을 등록하고 시작 날짜를 설정해보세요!</p>
|
||||
<button onclick="goBack()" class="mt-4 btn-primary px-6 py-2 rounded-lg">
|
||||
<i class="fas fa-arrow-left mr-2"></i>메인으로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script>
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuthStatus();
|
||||
loadTodoItems();
|
||||
});
|
||||
|
||||
// 뒤로 가기
|
||||
function goBack() {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
|
||||
// Todo 항목 로드
|
||||
async function loadTodoItems() {
|
||||
try {
|
||||
// API에서 Todo 카테고리 항목들만 가져오기
|
||||
const items = await TodoAPI.getTodos(null, 'todo');
|
||||
renderTodoItems(items);
|
||||
} catch (error) {
|
||||
console.error('Todo 항목 로드 실패:', error);
|
||||
renderTodoItems([]);
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 항목 렌더링
|
||||
function renderTodoItems(items) {
|
||||
const todoList = document.getElementById('todoList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
|
||||
if (items.length === 0) {
|
||||
todoList.innerHTML = '';
|
||||
emptyState.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
|
||||
todoList.innerHTML = items.map(item => `
|
||||
<div class="todo-item p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- 상태 아이콘 -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center ${getStatusColor(item.status)}">
|
||||
<i class="fas ${getStatusIcon(item.status)} text-sm"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 (있는 경우) -->
|
||||
${item.image_urls && item.image_urls.length > 0 ? `
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex space-x-2">
|
||||
${item.image_urls.slice(0, 3).map(url => `
|
||||
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
|
||||
`).join('')}
|
||||
${item.image_urls.length > 3 ? `
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-gray-900 font-medium mb-2">${item.title}</h4>
|
||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||
<span>
|
||||
<i class="fas fa-calendar mr-1"></i>마감일: ${formatDate(item.due_date)}
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1"></i>등록: ${formatDate(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="flex-shrink-0 flex space-x-2">
|
||||
${item.status !== 'completed' ? `
|
||||
<button onclick="delayTodo('${item.id}')" class="text-orange-500 hover:text-orange-700" title="지연하기">
|
||||
<i class="fas fa-clock"></i>
|
||||
</button>
|
||||
<button onclick="completeTodo('${item.id}')" class="text-green-500 hover:text-green-700" title="완료하기">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button onclick="splitTodo('${item.id}')" class="text-purple-500 hover:text-purple-700" title="분할하기">
|
||||
<i class="fas fa-cut"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button onclick="editTodo('${item.id}')" class="text-gray-400 hover:text-blue-500" title="수정하기">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 상태별 색상
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
scheduled: 'bg-blue-100 text-blue-600',
|
||||
active: 'bg-orange-100 text-orange-600',
|
||||
completed: 'bg-green-100 text-green-600'
|
||||
};
|
||||
return colors[status] || 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
|
||||
// 상태별 아이콘
|
||||
function getStatusIcon(status) {
|
||||
const icons = {
|
||||
scheduled: 'fa-calendar',
|
||||
active: 'fa-play',
|
||||
completed: 'fa-check'
|
||||
};
|
||||
return icons[status] || 'fa-circle';
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '날짜 없음';
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '날짜 없음';
|
||||
return date.toLocaleDateString('ko-KR');
|
||||
}
|
||||
|
||||
// Todo 지연
|
||||
function delayTodo(id) {
|
||||
const newDate = prompt('새로운 시작 날짜를 입력하세요 (YYYY-MM-DD):');
|
||||
if (newDate && /^\d{4}-\d{2}-\d{2}$/.test(newDate)) {
|
||||
// TODO: API 호출하여 due_date 업데이트
|
||||
console.log('Todo 지연:', id, '새 날짜:', newDate);
|
||||
alert(`할 일이 ${newDate}로 지연되었습니다.`);
|
||||
loadTodoItems(); // 목록 새로고침
|
||||
} else if (newDate) {
|
||||
alert('올바른 날짜 형식을 입력해주세요 (YYYY-MM-DD)');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 완료
|
||||
async function completeTodo(id) {
|
||||
try {
|
||||
// TODO: API 호출하여 상태를 'completed'로 변경
|
||||
console.log('Todo 완료:', id);
|
||||
alert('할 일이 완료되었습니다!');
|
||||
loadTodoItems(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error('완료 처리 실패:', error);
|
||||
alert('완료 처리에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 분할
|
||||
function splitTodo(id) {
|
||||
const splitCount = prompt('몇 개로 분할하시겠습니까? (2-5개):');
|
||||
const count = parseInt(splitCount);
|
||||
|
||||
if (count >= 2 && count <= 5) {
|
||||
const subtasks = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const subtask = prompt(`${i}번째 세부 작업을 입력하세요:`);
|
||||
if (subtask) {
|
||||
subtasks.push(subtask.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (subtasks.length > 0) {
|
||||
// TODO: API 호출하여 원본 삭제 후 세부 작업들 생성
|
||||
console.log('Todo 분할:', id, '세부 작업들:', subtasks);
|
||||
alert(`할 일이 ${subtasks.length}개의 세부 작업으로 분할되었습니다.`);
|
||||
loadTodoItems(); // 목록 새로고침
|
||||
}
|
||||
} else if (splitCount) {
|
||||
alert('2-5개 사이의 숫자를 입력해주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// Todo 편집
|
||||
function editTodo(id) {
|
||||
console.log('Todo 편집:', id);
|
||||
// TODO: 편집 모달 또는 페이지로 이동
|
||||
alert('편집 기능은 준비 중입니다.');
|
||||
}
|
||||
|
||||
// 필터링
|
||||
function filterTodos(filter) {
|
||||
console.log('필터:', filter);
|
||||
// TODO: 필터에 따라 목록 재로드
|
||||
}
|
||||
|
||||
// 대시보드로 이동
|
||||
function goToDashboard() {
|
||||
window.location.href = 'dashboard.html';
|
||||
}
|
||||
|
||||
// 전역 함수 등록
|
||||
window.goToDashboard = goToDashboard;
|
||||
</script>
|
||||
<script src="static/js/api.js?v=20250921110800"></script>
|
||||
<script src="static/js/auth.js?v=20250921110800"></script>
|
||||
</body>
|
||||
</html>
|
||||
853
frontend/upload.html
Normal file
853
frontend/upload.html
Normal file
@@ -0,0 +1,853 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>새 메모 - Todo Project</title>
|
||||
<link rel="icon" type="image/x-icon" href="static/icons/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<meta name="theme-color" content="#d4af37">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Todo Project">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--parchment: #f7f3e9;
|
||||
--parchment-dark: #f0ead6;
|
||||
--ink: #2c1810;
|
||||
--ink-light: #5d4e37;
|
||||
--sepia: #8b7355;
|
||||
--gold: #d4af37;
|
||||
--shadow: rgba(139, 115, 85, 0.2);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
background: linear-gradient(135deg, #f7f3e9 0%, #f0ead6 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.parchment-container {
|
||||
background: var(--parchment);
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 25%, rgba(139, 115, 85, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 75% 75%, rgba(139, 115, 85, 0.05) 0%, transparent 50%);
|
||||
border: 2px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 8px 32px var(--shadow),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.parchment-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
background: linear-gradient(45deg, var(--gold), var(--sepia), var(--gold));
|
||||
border-radius: 10px;
|
||||
z-index: -1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.memo-textarea {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--ink);
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
padding: 2rem;
|
||||
background-image: repeating-linear-gradient(
|
||||
transparent,
|
||||
transparent 1.7rem,
|
||||
rgba(139, 115, 85, 0.1) 1.7rem,
|
||||
rgba(139, 115, 85, 0.1) 1.8rem
|
||||
);
|
||||
}
|
||||
|
||||
.memo-textarea::placeholder {
|
||||
color: var(--ink-light);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.memo-textarea:focus {
|
||||
background-image: repeating-linear-gradient(
|
||||
transparent,
|
||||
transparent 1.7rem,
|
||||
rgba(139, 115, 85, 0.2) 1.7rem,
|
||||
rgba(139, 115, 85, 0.2) 1.8rem
|
||||
);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: linear-gradient(135deg, var(--sepia), var(--ink-light));
|
||||
color: var(--parchment);
|
||||
border: 2px solid var(--gold);
|
||||
border-radius: 25px;
|
||||
padding: 0.75rem 2rem;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px var(--shadow);
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px var(--shadow);
|
||||
background: linear-gradient(135deg, var(--ink-light), var(--ink));
|
||||
}
|
||||
|
||||
.photo-button {
|
||||
background: var(--parchment-dark);
|
||||
border: 2px dashed var(--sepia);
|
||||
color: var(--ink-light);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
font-family: 'Noto Serif KR', serif;
|
||||
}
|
||||
|
||||
.photo-button:hover {
|
||||
background: var(--parchment);
|
||||
border-color: var(--gold);
|
||||
color: var(--ink);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mobile-upload {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.desktop-upload {
|
||||
display: none;
|
||||
}
|
||||
.mobile-upload {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.memo-textarea {
|
||||
min-height: 180px;
|
||||
padding: 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 모바일에서 최근 메모 섹션 간소화 */
|
||||
.parchment-container {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 모바일에서 헤더 패딩 줄이기 */
|
||||
.header-vintage .max-w-4xl {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 모바일에서 메인 패딩 줄이기 */
|
||||
main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 모바일에서 키보드 대응을 위한 스타일 */
|
||||
.memo-textarea:focus {
|
||||
/* 포커스 시 더 부드러운 전환 */
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* iOS Safari에서 키보드로 인한 뷰포트 변화 대응 */
|
||||
body {
|
||||
/* 키보드가 나타날 때 레이아웃 깨짐 방지 */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
background: var(--parchment-dark);
|
||||
border: 1px solid var(--sepia);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--sepia);
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-remove {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
background: var(--ink);
|
||||
color: var(--parchment);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.header-vintage {
|
||||
background: linear-gradient(135deg, var(--parchment), var(--parchment-dark));
|
||||
border-bottom: 3px solid var(--gold);
|
||||
box-shadow: 0 2px 10px var(--shadow);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.recent-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background-color: var(--sepia);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--sepia);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background-color: var(--gold);
|
||||
border-color: var(--gold);
|
||||
color: var(--ink);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
background-color: var(--gold);
|
||||
border-color: var(--gold);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="header-vintage">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h1 class="text-xl font-semibold" style="color: var(--ink);">
|
||||
<i class="fas fa-feather-alt mr-2" style="color: var(--gold);"></i>
|
||||
새 메모
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex space-x-1">
|
||||
<a href="upload.html" class="nav-button active">
|
||||
<i class="fas fa-feather-alt mr-1"></i>메모
|
||||
</a>
|
||||
<a href="inbox.html" class="nav-button">
|
||||
<i class="fas fa-inbox mr-1"></i>수신함
|
||||
</a>
|
||||
<a href="todo-list.html" class="nav-button">
|
||||
<i class="fas fa-tasks mr-1"></i>Todo
|
||||
</a>
|
||||
<a href="board.html" class="nav-button">
|
||||
<i class="fas fa-clipboard mr-1"></i>보드
|
||||
</a>
|
||||
<a href="archive.html" class="nav-button">
|
||||
<i class="fas fa-archive mr-1"></i>아카이브
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- 최근 업로드 항목 (상단으로 이동) -->
|
||||
<div class="mb-6">
|
||||
<div class="parchment-container">
|
||||
<div class="px-6 py-4">
|
||||
<h3 class="text-lg font-medium mb-4 text-center" style="color: var(--ink);">
|
||||
<i class="fas fa-history mr-2" style="color: var(--gold);"></i>
|
||||
최근 저장된 메모
|
||||
</h3>
|
||||
<div id="recentUploads" class="space-y-3">
|
||||
<!-- 최근 업로드 항목들이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 입력 폼 -->
|
||||
<div class="parchment-container">
|
||||
<form id="uploadForm">
|
||||
<!-- 사진 버튼들 (상단으로 이동) -->
|
||||
<div class="px-6 pt-6 pb-4">
|
||||
<!-- 데스크톱용 -->
|
||||
<div class="desktop-upload">
|
||||
<button type="button" onclick="selectFile()" class="photo-button w-full text-center">
|
||||
<i class="fas fa-images mr-2"></i>
|
||||
사진 첨부
|
||||
</button>
|
||||
<input type="file" id="desktopFileInput" accept="image/*" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- 모바일용 -->
|
||||
<div class="mobile-upload">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button type="button" onclick="openCamera()" class="photo-button text-center">
|
||||
<i class="fas fa-camera mb-2 block text-lg"></i>
|
||||
<span class="text-sm">사진 촬영</span>
|
||||
</button>
|
||||
<button type="button" onclick="openGallery()" class="photo-button text-center">
|
||||
<i class="fas fa-images mb-2 block text-lg"></i>
|
||||
<span class="text-sm">갤러리</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="file" id="cameraInput" accept="image/*" capture="camera" multiple class="hidden">
|
||||
<input type="file" id="galleryInput" accept="image/*" multiple class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- 사진 미리보기 -->
|
||||
<div id="photoPreviewGrid" class="hidden mt-4">
|
||||
<div class="photo-preview">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="text-sm font-medium" style="color: var(--ink-light);">
|
||||
첨부된 사진 (<span id="photoCount">0</span>/5)
|
||||
</span>
|
||||
<button type="button" onclick="removeAllPhotos()"
|
||||
class="text-xs" style="color: var(--sepia);">
|
||||
<i class="fas fa-trash mr-1"></i>모두 삭제
|
||||
</button>
|
||||
</div>
|
||||
<div id="photoGrid" class="photo-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메모 입력 영역 (하단으로 이동) -->
|
||||
<textarea
|
||||
id="uploadContent"
|
||||
class="memo-textarea"
|
||||
placeholder="오늘 있었던 일, 떠오른 생각, 중요한 메모... 무엇이든 자유롭게 적어보세요."
|
||||
required
|
||||
autofocus></textarea>
|
||||
|
||||
<!-- 저장 버튼 -->
|
||||
<div class="px-6 pb-6">
|
||||
<div class="text-center">
|
||||
<button type="submit" class="action-button">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
메모 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 로딩 오버레이 -->
|
||||
<div id="loadingOverlay" class="hidden fixed inset-0 flex items-center justify-center z-50" style="background: rgba(44, 24, 16, 0.7);">
|
||||
<div class="parchment-container p-6 flex items-center space-x-3">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--gold);"></div>
|
||||
<span style="color: var(--ink); font-family: 'Noto Serif KR', serif;">메모 저장 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/auth.js"></script>
|
||||
<script src="static/js/image-utils.js"></script>
|
||||
<script>
|
||||
let currentPhotos = [];
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 인증 확인 (여러 토큰 키 확인)
|
||||
const token = localStorage.getItem('authToken') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
console.log('토큰이 없습니다. 대시보드로 이동합니다.');
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('인증 토큰 확인됨:', token ? '있음' : '없음');
|
||||
|
||||
// 이벤트 리스너 설정
|
||||
setupEventListeners();
|
||||
|
||||
// 최근 업로드 항목 로드
|
||||
loadRecentUploads();
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
// 폼 제출
|
||||
document.getElementById('uploadForm').addEventListener('submit', handleUploadSubmit);
|
||||
|
||||
// 파일 입력 이벤트
|
||||
document.getElementById('desktopFileInput').addEventListener('change', handleFileSelect);
|
||||
document.getElementById('cameraInput').addEventListener('change', handleFileSelect);
|
||||
document.getElementById('galleryInput').addEventListener('change', handleFileSelect);
|
||||
|
||||
// 드래그 앤 드롭
|
||||
const uploadArea = document.querySelector('.desktop-upload .border-dashed');
|
||||
if (uploadArea) {
|
||||
uploadArea.addEventListener('dragover', handleDragOver);
|
||||
uploadArea.addEventListener('drop', handleDrop);
|
||||
}
|
||||
|
||||
// 모바일 키보드 대응 설정
|
||||
setupMobileKeyboardHandling();
|
||||
}
|
||||
|
||||
|
||||
// 모바일 키보드 대응 설정
|
||||
function setupMobileKeyboardHandling() {
|
||||
const textarea = document.getElementById('uploadContent');
|
||||
|
||||
// iOS Safari와 Android Chrome에서 키보드 대응
|
||||
textarea.addEventListener('focus', function() {
|
||||
// 모바일 디바이스 감지
|
||||
if (window.innerWidth <= 768) {
|
||||
// 약간의 지연 후 스크롤 (키보드 애니메이션 완료 대기)
|
||||
setTimeout(() => {
|
||||
// textarea를 화면 중앙으로 부드럽게 스크롤
|
||||
textarea.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
// 키보드가 사라질 때 처리
|
||||
textarea.addEventListener('blur', function() {
|
||||
if (window.innerWidth <= 768) {
|
||||
// 키보드가 사라진 후 전체 레이아웃 복원
|
||||
setTimeout(() => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 뷰포트 크기 변화 감지 (키보드 나타남/사라짐)
|
||||
let initialViewportHeight = window.innerHeight;
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
const currentHeight = window.innerHeight;
|
||||
const heightDifference = initialViewportHeight - currentHeight;
|
||||
|
||||
// 키보드가 나타났을 때 (높이가 150px 이상 줄어들었을 때)
|
||||
if (heightDifference > 150 && document.activeElement === textarea) {
|
||||
setTimeout(() => {
|
||||
textarea.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// 터치 시작 시 현재 뷰포트 높이 업데이트
|
||||
document.addEventListener('touchstart', function() {
|
||||
initialViewportHeight = window.innerHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// 파일 선택
|
||||
function selectFile() {
|
||||
document.getElementById('desktopFileInput').click();
|
||||
}
|
||||
|
||||
// 카메라 열기
|
||||
function openCamera() {
|
||||
document.getElementById('cameraInput').click();
|
||||
}
|
||||
|
||||
// 갤러리 열기
|
||||
function openGallery() {
|
||||
document.getElementById('galleryInput').click();
|
||||
}
|
||||
|
||||
// 드래그 오버
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.add('border-purple-300', 'bg-purple-50');
|
||||
}
|
||||
|
||||
// 드롭
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('border-purple-300', 'bg-purple-50');
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'));
|
||||
processFiles(files);
|
||||
}
|
||||
|
||||
// 파일 선택 처리
|
||||
function handleFileSelect(e) {
|
||||
const files = Array.from(e.target.files);
|
||||
processFiles(files);
|
||||
}
|
||||
|
||||
// 파일 처리
|
||||
async function processFiles(files) {
|
||||
if (currentPhotos.length + files.length > 5) {
|
||||
alert('최대 5장까지만 업로드할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
let processedFile = file;
|
||||
|
||||
// 간단한 이미지 압축 시도
|
||||
try {
|
||||
// 파일 크기가 2MB 이상인 경우 압축 시도
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
console.log(`큰 파일 감지 (${(file.size / 1024 / 1024).toFixed(2)}MB), 압축 시도:`, file.name);
|
||||
const compressed = await compressImageSimple(file, 0.7, 1920);
|
||||
if (compressed && compressed.size < file.size) {
|
||||
processedFile = compressed;
|
||||
console.log(`압축 성공: ${(file.size / 1024 / 1024).toFixed(2)}MB → ${(compressed.size / 1024 / 1024).toFixed(2)}MB`);
|
||||
} else {
|
||||
console.warn('압축 효과 없음, 원본 사용:', file.name);
|
||||
}
|
||||
} else {
|
||||
console.log('작은 파일, 압축 생략:', file.name);
|
||||
}
|
||||
} catch (compressionError) {
|
||||
console.warn('이미지 압축 실패, 원본 사용:', compressionError);
|
||||
}
|
||||
|
||||
const base64 = await convertFileToBase64(processedFile);
|
||||
|
||||
currentPhotos.push({
|
||||
file: processedFile,
|
||||
base64: base64,
|
||||
name: file.name
|
||||
});
|
||||
|
||||
console.log('파일 처리 완료:', file.name);
|
||||
} catch (error) {
|
||||
console.error('이미지 처리 실패:', error);
|
||||
alert(`이미지 처리에 실패했습니다: ${file.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePhotoPreview();
|
||||
}
|
||||
|
||||
// 간단한 이미지 압축 함수
|
||||
function compressImageSimple(file, quality = 0.7, maxWidth = 1920) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = new Image();
|
||||
|
||||
img.onload = function() {
|
||||
// 비율 유지하면서 크기 조정
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width;
|
||||
width = maxWidth;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// 이미지 그리기
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Blob으로 변환
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('압축 실패'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error('이미지 로드 실패'));
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Base64 변환
|
||||
function convertFileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 파일이 유효한 Blob/File인지 확인
|
||||
if (!file || !(file instanceof Blob || file instanceof File)) {
|
||||
reject(new Error('유효하지 않은 파일 객체입니다.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = (error) => {
|
||||
console.error('FileReader 오류:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
try {
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('readAsDataURL 호출 오류:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 사진 미리보기 업데이트
|
||||
function updatePhotoPreview() {
|
||||
const previewGrid = document.getElementById('photoPreviewGrid');
|
||||
const photoGrid = document.getElementById('photoGrid');
|
||||
const photoCount = document.getElementById('photoCount');
|
||||
|
||||
if (currentPhotos.length === 0) {
|
||||
previewGrid.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
previewGrid.classList.remove('hidden');
|
||||
photoCount.textContent = currentPhotos.length;
|
||||
|
||||
photoGrid.innerHTML = currentPhotos.map((photo, index) => `
|
||||
<div class="photo-item">
|
||||
<img src="${photo.base64}" alt="${photo.name}">
|
||||
<button type="button" class="photo-remove" onclick="removePhoto(${index})">
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 사진 제거
|
||||
function removePhoto(index) {
|
||||
currentPhotos.splice(index, 1);
|
||||
updatePhotoPreview();
|
||||
}
|
||||
|
||||
// 모든 사진 제거
|
||||
function removeAllPhotos() {
|
||||
currentPhotos = [];
|
||||
updatePhotoPreview();
|
||||
}
|
||||
|
||||
// 업로드 제출
|
||||
async function handleUploadSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const content = document.getElementById('uploadContent').value.trim();
|
||||
if (!content) {
|
||||
alert('메모를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
document.getElementById('loadingOverlay').classList.remove('hidden');
|
||||
|
||||
const todoData = {
|
||||
title: null, // 메모는 제목 없음
|
||||
description: content,
|
||||
category: 'memo',
|
||||
image_urls: currentPhotos.map(photo => photo.base64)
|
||||
};
|
||||
|
||||
console.log('업로드 데이터:', todoData);
|
||||
|
||||
const response = await TodoAPI.createTodo(todoData);
|
||||
console.log('업로드 성공:', response);
|
||||
|
||||
// 성공 시 폼 초기화 및 메시지 표시
|
||||
showSuccessMessage('메모가 성공적으로 저장되었습니다!');
|
||||
clearForm();
|
||||
|
||||
} catch (error) {
|
||||
console.error('업로드 실패:', error);
|
||||
alert('업로드에 실패했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
document.getElementById('loadingOverlay').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 성공 메시지 표시
|
||||
function showSuccessMessage(message) {
|
||||
// 기존 메시지 제거
|
||||
const existingMessage = document.querySelector('.success-message');
|
||||
if (existingMessage) {
|
||||
existingMessage.remove();
|
||||
}
|
||||
|
||||
// 새 메시지 생성
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'success-message fixed top-4 left-1/2 transform -translate-x-1/2 z-50 px-6 py-3 rounded-lg shadow-lg';
|
||||
messageDiv.style.background = 'var(--gold)';
|
||||
messageDiv.style.color = 'var(--ink)';
|
||||
messageDiv.style.fontFamily = 'Noto Serif KR, serif';
|
||||
messageDiv.innerHTML = `<i class="fas fa-check-circle mr-2"></i>${message}`;
|
||||
|
||||
document.body.appendChild(messageDiv);
|
||||
|
||||
// 3초 후 제거
|
||||
setTimeout(() => {
|
||||
messageDiv.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
function clearForm() {
|
||||
document.getElementById('uploadContent').value = '';
|
||||
removeAllPhotos();
|
||||
document.getElementById('uploadContent').focus();
|
||||
// 최근 업로드 목록 새로고침
|
||||
loadRecentUploads();
|
||||
}
|
||||
|
||||
// 최근 업로드 항목 로드
|
||||
async function loadRecentUploads() {
|
||||
try {
|
||||
const response = await TodoAPI.getTodos(null, 'memo');
|
||||
const recentItems = response.slice(0, 3); // 최근 3개만
|
||||
renderRecentUploads(recentItems);
|
||||
} catch (error) {
|
||||
console.error('최근 업로드 로드 실패:', error);
|
||||
document.getElementById('recentUploads').innerHTML = `
|
||||
<div class="text-center py-4" style="color: var(--ink-light);">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||
최근 메모를 불러올 수 없습니다.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 업로드 항목 렌더링
|
||||
function renderRecentUploads(items) {
|
||||
const container = document.getElementById('recentUploads');
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-8" style="color: var(--ink-light);">
|
||||
<i class="fas fa-inbox mr-2"></i>
|
||||
아직 저장된 메모가 없습니다.
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = items.map(item => {
|
||||
const createdAt = new Date(item.created_at);
|
||||
const timeAgo = getTimeAgo(createdAt);
|
||||
const hasImages = item.image_urls && item.image_urls.length > 0;
|
||||
const content = item.description || item.title || '내용 없음';
|
||||
|
||||
return `
|
||||
<div class="recent-item p-3 rounded-lg border" style="background: var(--parchment-dark); border-color: var(--sepia);">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm line-clamp-2 mb-2" style="color: var(--ink);">
|
||||
${content.length > 80 ? content.substring(0, 80) + '...' : content}
|
||||
</p>
|
||||
<div class="flex items-center text-xs" style="color: var(--ink-light);">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<span>${timeAgo}</span>
|
||||
${hasImages ? `<i class="fas fa-images ml-3 mr-1"></i><span>${item.image_urls.length}장</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${hasImages && item.image_urls[0] ? `
|
||||
<div class="ml-3 flex-shrink-0">
|
||||
<img src="${item.image_urls[0]}" alt="첨부 이미지"
|
||||
class="w-12 h-12 object-cover rounded border"
|
||||
style="border-color: var(--sepia);">
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 시간 경과 표시
|
||||
function getTimeAgo(date) {
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return '방금 전';
|
||||
if (diffMins < 60) return `${diffMins}분 전`;
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
if (diffDays < 7) return `${diffDays}일 전`;
|
||||
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// 전역 함수 등록
|
||||
window.selectFile = selectFile;
|
||||
window.openCamera = openCamera;
|
||||
window.openGallery = openGallery;
|
||||
window.removePhoto = removePhoto;
|
||||
window.removeAllPhotos = removeAllPhotos;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user