🚀 시놀로지 배포 준비 완료

 주요 변경사항:
- 단일 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:
Hyungi Ahn
2025-09-24 09:12:39 +09:00
parent 4c7d2d8290
commit 0b967a84fa
42 changed files with 5467 additions and 6691 deletions

192
README.md
View File

@@ -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)

View 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가 시놀로지에 성공적으로 배포되었습니다!**

View File

@@ -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를 사용하세요! 🎉

View File

@@ -5,6 +5,7 @@ WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 설치

View 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;

View File

@@ -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())

View 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="시스템 초기화 중 오류가 발생했습니다."
)

View File

@@ -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

View File

@@ -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"]
# 서버 설정

View File

@@ -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}명의 사용자가 등록되어 있습니다.")

View File

@@ -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"

View File

@@ -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", "업무"]
}

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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)}")

View File

@@ -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()

View File

@@ -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")

View File

@@ -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]
# 간단한 워크플로우에 맞게 통계 기능 제거

View File

@@ -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__ = []

View File

@@ -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

View File

@@ -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:

View File

@@ -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
View 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 "$@"

View File

@@ -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
View 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. 포트가 시놀로지에서 사용 가능한지 확인
# =============================================================================

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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
View 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;
}
}

View File

@@ -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() {

View File

@@ -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;
}

View File

@@ -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
View 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>

View File

@@ -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
View 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>