Initial commit: Todo Project with dashboard, classification center, and upload functionality

- 📱 PWA 지원: 홈화면 추가 가능한 Progressive Web App
- 🎨 M-Project 색상 스키마: 하늘색, 주황색, 회색, 흰색 일관된 디자인
- 📊 대시보드: 데스크톱 캘린더 뷰 + 모바일 일일 뷰 반응형 디자인
- 📥 분류 센터: Gmail 스타일 받은편지함으로 스마트 분류 시스템
- 🤖 AI 분류 제안: 키워드 기반 자동 분류 제안 및 일괄 처리
- 📷 업로드 모달: 데스크톱(파일 선택) + 모바일(카메라/갤러리) 최적화
- 🏷️ 3가지 분류: Todo(시작일), 캘린더(마감일), 체크리스트(무기한)
- 📋 체크리스트: 진행률 표시 및 완료 토글 기능
- 🔄 시놀로지 연동 준비: 메일플러스 연동을 위한 구조 설계
- 📱 반응형 UI: 모든 페이지 모바일 최적화 완료
This commit is contained in:
Hyungi Ahn
2025-09-19 08:52:49 +09:00
commit 761757c12e
60 changed files with 10281 additions and 0 deletions

21
.env.example Normal file
View File

@@ -0,0 +1,21 @@
# Database Configuration
DATABASE_URL=postgresql://todo_user:todo_password@localhost:5434/todo_db
POSTGRES_USER=todo_user
POSTGRES_PASSWORD=todo_password
POSTGRES_DB=todo_db
# JWT Configuration
SECRET_KEY=your-secret-key-here-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# Application Configuration
DEBUG=true
CORS_ORIGINS=["http://localhost:4000", "http://127.0.0.1:4000"]
# Server Configuration
HOST=0.0.0.0
PORT=9000
# Frontend Configuration
FRONTEND_PORT=4000

152
.gitignore vendored Normal file
View File

@@ -0,0 +1,152 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Docker
.dockerignore
# Database
*.db
*.sqlite
*.sqlite3
# Uploads
uploads/

1052
COMPREHENSIVE_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

26
backend/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.11-slim
WORKDIR /app
# 시스템 패키지 업데이트 및 필요한 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 설치
COPY pyproject.toml ./
RUN pip install --no-cache-dir -e .
# 애플리케이션 코드 복사
COPY src/ ./src/
COPY migrations/ ./migrations/
# 환경 변수 설정
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
# 포트 노출
EXPOSE 9000
# 애플리케이션 실행
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "9000", "--reload"]

57
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,57 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "todo-project"
version = "0.1.0"
description = "독립적인 할일 관리 시스템"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"fastapi>=0.104.1",
"uvicorn[standard]>=0.24.0",
"sqlalchemy>=2.0.23",
"alembic>=1.12.1",
"asyncpg>=0.29.0",
"python-multipart>=0.0.6",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"python-dotenv>=1.0.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
"pillow>=10.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.3",
"pytest-asyncio>=0.21.1",
"httpx>=0.25.2",
"black>=23.11.0",
"isort>=5.12.0",
"flake8>=6.1.0",
]
[tool.black]
line-length = 88
target-version = ['py38']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
[tool.isort]
profile = "black"
multi_line_output = 3
line_length = 88

0
backend/src/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,94 @@
"""
API 의존성
"""
from fastapi import Depends, HTTPException, status, Query
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from ..core.database import get_db
from ..core.security import verify_token, get_user_id_from_token
from ..models.user import User
# HTTP Bearer 토큰 스키마 (선택적)
security = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db)
) -> User:
"""현재 로그인된 사용자 가져오기"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
try:
# 토큰에서 사용자 ID 추출
user_id = get_user_id_from_token(credentials.credentials)
# 데이터베이스에서 사용자 조회
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
return user
except Exception as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""활성 사용자 확인"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
async def get_current_admin_user(
current_user: User = Depends(get_current_active_user)
) -> User:
"""관리자 권한 확인"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
async def get_optional_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db)
) -> Optional[User]:
"""선택적 사용자 인증 (토큰이 없어도 됨)"""
if not credentials:
return None
try:
return await get_current_user(credentials, db)
except HTTPException:
return None

View File

@@ -0,0 +1,7 @@
"""
API 라우터 모듈
"""
from . import auth, todos, calendar
__all__ = ["auth", "todos", "calendar"]

View File

@@ -0,0 +1,195 @@
"""
인증 관련 API 라우터
- API 라우터 기준: 최대 400줄
- 간결함 원칙: 필수 인증 기능만 포함
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from datetime import datetime
from ...core.database import get_db
from ...core.security import verify_password, create_access_token, create_refresh_token, get_password_hash
from ...core.config import settings
from ...models.user import User
from ...schemas.auth import (
LoginRequest, TokenResponse, RefreshTokenRequest,
UserInfo, ChangePasswordRequest, CreateUserRequest
)
from ..dependencies import get_current_active_user, get_current_admin_user
router = APIRouter()
@router.post("/login", response_model=TokenResponse)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db)
):
"""사용자 로그인"""
# 사용자 조회
result = await db.execute(
select(User).where(User.email == login_data.email)
)
user = result.scalar_one_or_none()
# 사용자 존재 및 비밀번호 확인
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
# 비활성 사용자 확인
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Inactive user"
)
# 사용자별 세션 타임아웃을 적용한 토큰 생성
access_token = create_access_token(
data={"sub": str(user.id)},
timeout_minutes=user.session_timeout_minutes
)
refresh_token = create_refresh_token(data={"sub": str(user.id)})
# 마지막 로그인 시간 업데이트
await db.execute(
update(User)
.where(User.id == user.id)
.values(last_login=datetime.utcnow())
)
await db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
refresh_data: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
):
"""토큰 갱신"""
from ...core.security import verify_token
try:
# 리프레시 토큰 검증
payload = verify_token(refresh_data.refresh_token, token_type="refresh")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# 사용자 존재 확인
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# 새 토큰 생성
access_token = create_access_token(data={"sub": str(user.id)})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
@router.get("/me", response_model=UserInfo)
async def get_current_user_info(
current_user: User = Depends(get_current_active_user)
):
"""현재 사용자 정보 조회"""
return UserInfo.model_validate(current_user)
@router.put("/change-password")
async def change_password(
password_data: ChangePasswordRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""비밀번호 변경"""
# 현재 비밀번호 확인
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
# 새 비밀번호 해싱 및 업데이트
new_hashed_password = get_password_hash(password_data.new_password)
await db.execute(
update(User)
.where(User.id == current_user.id)
.values(hashed_password=new_hashed_password)
)
await db.commit()
return {"message": "Password changed successfully"}
@router.post("/create-user", response_model=UserInfo)
async def create_user(
user_data: CreateUserRequest,
admin_user: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_db)
):
"""새 사용자 생성 (관리자 전용)"""
# 이메일 중복 확인
result = await db.execute(
select(User).where(User.email == user_data.email)
)
existing_user = result.scalar_one_or_none()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# 새 사용자 생성
new_user = User(
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
full_name=user_data.full_name,
is_admin=user_data.is_admin,
is_active=True
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return UserInfo.from_orm(new_user)
@router.post("/logout")
async def logout(
current_user: User = Depends(get_current_active_user)
):
"""로그아웃 (클라이언트에서 토큰 삭제)"""
# 실제로는 클라이언트에서 토큰을 삭제하면 됨
# 필요시 토큰 블랙리스트 구현 가능
return {"message": "Logged out successfully"}

View File

@@ -0,0 +1,175 @@
"""
캘린더 연동 API 라우터
- API 라우터 기준: 최대 400줄
- 간결함 원칙: 캘린더 설정 및 동기화 기능만 포함
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Dict, Any, Optional
from uuid import UUID
import logging
from ...core.database import get_db
from ...models.user import User
from ...models.todo import TodoItem
from ..dependencies import get_current_active_user
from ...integrations.calendar import get_calendar_router, CalendarProvider
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/calendar", tags=["calendar"])
@router.post("/providers/register")
async def register_calendar_provider(
provider_data: Dict[str, Any],
current_user: User = Depends(get_current_active_user)
):
"""캘린더 제공자 등록"""
try:
provider_name = provider_data.get("provider")
credentials = provider_data.get("credentials", {})
set_as_default = provider_data.get("default", False)
if not provider_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Provider name is required"
)
provider = CalendarProvider(provider_name)
calendar_router = get_calendar_router()
success = await calendar_router.register_provider(
provider, credentials, set_as_default
)
if success:
return {"message": f"{provider_name} 캘린더 등록 성공"}
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{provider_name} 캘린더 등록 실패"
)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="지원하지 않는 캘린더 제공자"
)
except Exception as e:
logger.error(f"캘린더 제공자 등록 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.get("/providers")
async def get_registered_providers(
current_user: User = Depends(get_current_active_user)
):
"""등록된 캘린더 제공자 목록 조회"""
try:
calendar_router = get_calendar_router()
providers = calendar_router.get_registered_providers()
return {
"providers": providers,
"default": calendar_router.default_provider.value if calendar_router.default_provider else None
}
except Exception as e:
logger.error(f"캘린더 제공자 조회 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.get("/calendars")
async def get_all_calendars(
current_user: User = Depends(get_current_active_user)
):
"""모든 등록된 제공자의 캘린더 목록 조회"""
try:
calendar_router = get_calendar_router()
calendars = await calendar_router.get_all_calendars()
return calendars
except Exception as e:
logger.error(f"캘린더 목록 조회 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.post("/sync/{todo_id}")
async def sync_todo_to_calendar(
todo_id: UUID,
sync_config: Optional[Dict[str, Any]] = None,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""특정 할일을 캘린더에 동기화"""
try:
from sqlalchemy import select, and_
# 할일 조회
result = await db.execute(
select(TodoItem).where(
and_(
TodoItem.id == todo_id,
TodoItem.user_id == current_user.id
)
)
)
todo_item = result.scalar_one_or_none()
if not todo_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo item not found"
)
# 캘린더 동기화
calendar_router = get_calendar_router()
calendar_configs = sync_config.get("calendars") if sync_config else None
result = await calendar_router.sync_todo_to_calendars(
todo_item, calendar_configs
)
return {
"message": "캘린더 동기화 완료",
"result": result
}
except HTTPException:
raise
except Exception as e:
logger.error(f"캘린더 동기화 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.get("/health")
async def calendar_health_check(
current_user: User = Depends(get_current_active_user)
):
"""캘린더 서비스 상태 확인"""
try:
calendar_router = get_calendar_router()
health_status = await calendar_router.health_check()
return health_status
except Exception as e:
logger.error(f"캘린더 상태 확인 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)

View File

@@ -0,0 +1,313 @@
"""
할일관리 API 라우터 (간결 버전)
- API 라우터 기준: 최대 400줄
- 간결함 원칙: 라우팅만 담당, 비즈니스 로직은 서비스로 분리
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List, Optional
from uuid import UUID
import logging
from ...core.database import get_db
from ...models.user import User
from ...schemas.todo import (
TodoItemCreate, TodoItemSchedule, TodoItemDelay, TodoItemSplit,
TodoItemResponse, TodoCommentCreate, TodoCommentResponse
)
from ..dependencies import get_current_active_user
from ...services.todo_service import TodoService
from ...services.calendar_sync_service import get_calendar_sync_service
from ...services.file_service import save_base64_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="이미지 파일만 업로드 가능합니다."
)
# 파일 크기 확인 (5MB 제한)
contents = await image.read()
if len(contents) > 5 * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="파일 크기는 5MB를 초과할 수 없습니다."
)
# Base64로 변환하여 저장
import base64
base64_string = f"data:{image.content_type};base64,{base64.b64encode(contents).decode()}"
# 파일 저장
file_url = save_base64_image(base64_string)
if not file_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="이미지 저장에 실패했습니다."
)
return {"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="이미지 업로드에 실패했습니다."
)
# ============================================================================
# 할일 아이템 관리
# ============================================================================
@router.post("/", response_model=TodoItemResponse)
async def create_todo_item(
todo_data: TodoItemCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""새 할일 생성"""
try:
service = TodoService(db)
return await service.create_todo(todo_data, current_user.id)
except Exception as e:
await db.rollback()
logger.error(f"할일 생성 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.post("/{todo_id}/schedule", response_model=TodoItemResponse)
async def schedule_todo_item(
todo_id: UUID,
schedule_data: TodoItemSchedule,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 일정 설정 및 캘린더 동기화"""
try:
service = TodoService(db)
result = await service.schedule_todo(todo_id, schedule_data, current_user.id)
# 🔄 캘린더 동기화 (백그라운드)
sync_service = get_calendar_sync_service()
todo_item = await service._get_user_todo(todo_id, current_user.id)
await sync_service.sync_todo_create(todo_item)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
await db.rollback()
logger.error(f"할일 일정 설정 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.put("/{todo_id}/complete", response_model=TodoItemResponse)
async def complete_todo_item(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 완료 및 캘린더 업데이트"""
try:
service = TodoService(db)
result = await service.complete_todo(todo_id, current_user.id)
# 🔄 캘린더 동기화 (완료 태그 변경)
sync_service = get_calendar_sync_service()
todo_item = await service._get_user_todo(todo_id, current_user.id)
await sync_service.sync_todo_complete(todo_item)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
await db.rollback()
logger.error(f"할일 완료 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.put("/{todo_id}/delay", response_model=TodoItemResponse)
async def delay_todo_item(
todo_id: UUID,
delay_data: TodoItemDelay,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 지연 및 캘린더 날짜 수정"""
try:
service = TodoService(db)
result = await service.delay_todo(todo_id, delay_data, current_user.id)
# 🔄 캘린더 동기화 (날짜 수정)
sync_service = get_calendar_sync_service()
todo_item = await service._get_user_todo(todo_id, current_user.id)
await sync_service.sync_todo_delay(todo_item)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
await db.rollback()
logger.error(f"할일 지연 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.post("/{todo_id}/split", response_model=List[TodoItemResponse])
async def split_todo_item(
todo_id: UUID,
split_data: TodoItemSplit,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 분할"""
try:
service = TodoService(db)
return await service.split_todo(todo_id, split_data, current_user.id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
await db.rollback()
logger.error(f"할일 분할 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.get("/", response_model=List[TodoItemResponse])
async def get_todo_items(
status: Optional[str] = Query(None, regex="^(draft|scheduled|active|completed|delayed)$"),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 목록 조회"""
try:
service = TodoService(db)
return await service.get_todos(current_user.id, status)
except Exception as e:
logger.error(f"할일 목록 조회 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.get("/active", response_model=List[TodoItemResponse])
async def get_active_todos(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""오늘 활성화된 할일들 조회"""
try:
service = TodoService(db)
return await service.get_active_todos(current_user.id)
except Exception as e:
logger.error(f"활성 할일 조회 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
# ============================================================================
# 댓글 관리
# ============================================================================
@router.post("/{todo_id}/comments", response_model=TodoCommentResponse)
async def create_todo_comment(
todo_id: UUID,
comment_data: TodoCommentCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일에 댓글 추가"""
try:
service = TodoService(db)
return await service.create_comment(todo_id, comment_data, current_user.id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
await db.rollback()
logger.error(f"댓글 생성 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)
@router.get("/{todo_id}/comments", response_model=List[TodoCommentResponse])
async def get_todo_comments(
todo_id: UUID,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""할일 댓글 목록 조회"""
try:
service = TodoService(db)
return await service.get_comments(todo_id, current_user.id)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
logger.error(f"댓글 조회 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e)
)

View File

View File

@@ -0,0 +1,44 @@
"""
Todo Project 애플리케이션 설정
"""
from pydantic_settings import BaseSettings
from typing import List
import os
class Settings(BaseSettings):
"""애플리케이션 설정 클래스"""
# 기본 설정
APP_NAME: str = "Todo Project"
DEBUG: bool = True
VERSION: str = "0.1.0"
# 데이터베이스 설정
DATABASE_URL: str = "postgresql+asyncpg://todo_user:todo_password@localhost:5434/todo_db"
# JWT 설정
SECRET_KEY: str = "your-secret-key-change-this-in-production"
ALGORITHM: str = "HS256"
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"]
ALLOWED_ORIGINS: List[str] = ["http://localhost:4000", "http://127.0.0.1:4000"]
# 서버 설정
HOST: str = "0.0.0.0"
PORT: int = 9000
# 관리자 계정 설정 (초기 설정용)
ADMIN_EMAIL: str = "admin@todo-project.local"
ADMIN_PASSWORD: str = "admin123" # 프로덕션에서는 반드시 변경
class Config:
env_file = ".env"
case_sensitive = True
# 설정 인스턴스 생성
settings = Settings()

View File

@@ -0,0 +1,94 @@
"""
데이터베이스 설정 및 연결
"""
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import MetaData
from typing import AsyncGenerator
from .config import settings
# SQLAlchemy 메타데이터 설정
metadata = MetaData(
naming_convention={
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
)
class Base(DeclarativeBase):
"""SQLAlchemy Base 클래스"""
metadata = metadata
# 비동기 데이터베이스 엔진 생성
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
pool_pre_ping=True,
pool_recycle=300,
)
# 비동기 세션 팩토리
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""비동기 데이터베이스 세션 의존성"""
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""데이터베이스 초기화"""
from ..models import user, todo
async with engine.begin() as conn:
# 모든 테이블 생성
await conn.run_sync(Base.metadata.create_all)
# 관리자 계정 생성
await create_admin_user()
async def create_admin_user() -> None:
"""관리자 계정 생성 (존재하지 않을 경우)"""
from ..models.user import User
from .security import get_password_hash
from sqlalchemy import select
async with AsyncSessionLocal() as session:
# 관리자 계정 존재 확인
result = await session.execute(
select(User).where(User.email == settings.ADMIN_EMAIL)
)
admin_user = result.scalar_one_or_none()
if not admin_user:
# 관리자 계정 생성
admin_user = User(
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_EMAIL}")

View File

@@ -0,0 +1,94 @@
"""
보안 관련 유틸리티
"""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status
from .config import settings
# 비밀번호 해싱 컨텍스트
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""비밀번호 검증"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""비밀번호 해싱"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, timeout_minutes: Optional[int] = None) -> str:
"""액세스 토큰 생성"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
elif timeout_minutes is not None:
if timeout_minutes == 0:
# 무제한 토큰 (1년으로 설정)
expire = datetime.utcnow() + timedelta(days=365)
else:
expire = datetime.utcnow() + timedelta(minutes=timeout_minutes)
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""리프레시 토큰 생성"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_token(token: str, token_type: str = "access") -> dict:
"""토큰 검증"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# 토큰 타입 확인
if payload.get("type") != token_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type"
)
# 만료 시간 확인
exp = payload.get("exp")
if exp is None or datetime.utcnow() > datetime.fromtimestamp(exp):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired"
)
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
def get_user_id_from_token(token: str) -> str:
"""토큰에서 사용자 ID 추출"""
payload = verify_token(token)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials"
)
return user_id

View File

View File

@@ -0,0 +1,32 @@
"""
캘린더 통합 모듈
- 다중 캘린더 제공자 지원 (시놀로지, 애플, 구글 등)
- 간결한 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

@@ -0,0 +1,370 @@
"""
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

@@ -0,0 +1,332 @@
"""
캘린더 서비스 기본 인터페이스 및 추상화
"""
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

@@ -0,0 +1,363 @@
"""
캘린더 라우터 - 다중 캘린더 제공자 중앙 관리
- 서비스 클래스 기준: 최대 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

@@ -0,0 +1,401 @@
"""
시놀로지 캘린더 서비스 구현
"""
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)}")

95
backend/src/main.py Normal file
View File

@@ -0,0 +1,95 @@
"""
Todo-Project 메인 애플리케이션
- 간결함 원칙: 애플리케이션 설정 및 라우터 등록만 담당
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import logging
from .core.config import settings
from .api.routes import auth, todos, calendar
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
# FastAPI 앱 생성
app = FastAPI(
title="Todo-Project API",
description="간결한 Todo 관리 시스템 with 캘린더 연동",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 라우터 등록
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"])
@app.get("/")
async def root():
"""루트 엔드포인트"""
return {
"message": "Todo-Project API",
"version": "1.0.0",
"docs": "/docs"
}
@app.get("/health")
async def health_check():
"""헬스 체크"""
return {
"status": "healthy",
"service": "todo-project",
"version": "1.0.0"
}
# 애플리케이션 시작 시 실행
@app.on_event("startup")
async def startup_event():
"""애플리케이션 시작 시 초기화"""
logger.info("🚀 Todo-Project API 시작")
logger.info(f"📊 환경: {settings.ENVIRONMENT}")
logger.info(f"🔗 데이터베이스: {settings.DATABASE_URL}")
# 애플리케이션 종료 시 실행
@app.on_event("shutdown")
async def shutdown_event():
"""애플리케이션 종료 시 정리"""
logger.info("🛑 Todo-Project API 종료")
# 캘린더 서비스 연결 정리
try:
from .integrations.calendar import get_calendar_router
calendar_router = get_calendar_router()
await calendar_router.close_all()
logger.info("📅 캘린더 서비스 연결 정리 완료")
except Exception as e:
logger.error(f"캘린더 서비스 정리 중 오류: {e}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=settings.PORT,
reload=settings.DEBUG
)

View File

View File

@@ -0,0 +1,64 @@
"""
할일관리 시스템 모델
"""
from sqlalchemy import Column, String, DateTime, Text, Boolean, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from ..core.database import Base
class TodoItem(Base):
"""할일 아이템"""
__tablename__ = "todo_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# 기본 정보
content = Column(Text, nullable=False) # 할일 내용
status = Column(String(20), nullable=False, default="draft") # draft, scheduled, active, completed, delayed
photo_url = Column(String(500), nullable=True) # 첨부 사진 URL
# 시간 관리
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
start_date = Column(DateTime(timezone=True), nullable=True) # 시작 예정일
estimated_minutes = Column(Integer, nullable=True) # 예상 소요시간 (분)
completed_at = Column(DateTime(timezone=True), nullable=True)
delayed_until = Column(DateTime(timezone=True), nullable=True) # 지연된 경우 새로운 시작일
# 분할 관리
parent_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=True) # 분할된 할일의 부모
split_order = Column(Integer, nullable=True) # 분할 순서
# 관계
user = relationship("User", back_populates="todo_items")
comments = relationship("TodoComment", back_populates="todo_item", cascade="all, delete-orphan")
# 자기 참조 관계 (분할된 할일들)
subtasks = relationship("TodoItem", backref="parent_task", remote_side=[id])
def __repr__(self):
return f"<TodoItem(id={self.id}, content='{self.content[:50]}...', status='{self.status}')>"
class TodoComment(Base):
"""할일 댓글/메모"""
__tablename__ = "todo_comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
todo_item_id = Column(UUID(as_uuid=True), ForeignKey("todo_items.id"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
# 관계
todo_item = relationship("TodoItem", back_populates="comments")
user = relationship("User")
def __repr__(self):
return f"<TodoComment(id={self.id}, content='{self.content[:30]}...')>"

View File

@@ -0,0 +1,43 @@
"""
사용자 모델
"""
from sqlalchemy import Column, String, Boolean, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
import uuid
from ..core.database import Base
class User(Base):
"""사용자 모델"""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255), nullable=True)
# 상태 관리
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
# 타임스탬프
created_at = Column(DateTime(timezone=True), default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
last_login_at = Column(DateTime(timezone=True), nullable=True)
# 프로필 정보
bio = Column(Text, nullable=True)
avatar_url = Column(String(500), nullable=True)
# 설정
timezone = Column(String(50), default="Asia/Seoul", nullable=False)
language = Column(String(10), default="ko", nullable=False)
# 관계 설정
todo_items = relationship("TodoItem", back_populates="user", lazy="dynamic")
def __repr__(self):
return f"<User(email='{self.email}', full_name='{self.full_name}')>"

View File

View File

@@ -0,0 +1,57 @@
"""
인증 관련 스키마
"""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime
from uuid import UUID
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
is_active: bool = True
class UserCreate(UserBase):
password: str = Field(..., min_length=6)
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
is_active: Optional[bool] = None
class UserResponse(UserBase):
id: UUID
is_admin: bool
created_at: datetime
updated_at: datetime
last_login_at: Optional[datetime] = None
timezone: str
language: str
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class TokenData(BaseModel):
user_id: Optional[str] = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
remember_me: bool = False
class RefreshTokenRequest(BaseModel):
refresh_token: str

110
backend/src/schemas/todo.py Normal file
View File

@@ -0,0 +1,110 @@
"""
할일관리 시스템 스키마
"""
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from uuid import UUID
class TodoCommentBase(BaseModel):
content: str = Field(..., min_length=1, max_length=1000)
class TodoCommentCreate(TodoCommentBase):
pass
class TodoCommentUpdate(BaseModel):
content: Optional[str] = Field(None, min_length=1, max_length=1000)
class TodoCommentResponse(TodoCommentBase):
id: UUID
todo_item_id: UUID
user_id: UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TodoItemBase(BaseModel):
content: str = Field(..., min_length=1, max_length=2000)
class TodoItemCreate(TodoItemBase):
"""초기 할일 생성 (draft 상태)"""
photo_url: Optional[str] = Field(None, max_length=500, description="첨부 사진 URL")
class TodoItemSchedule(BaseModel):
"""할일 일정 설정"""
start_date: datetime
estimated_minutes: int = Field(..., ge=1, le=120) # 1분~2시간
class TodoItemUpdate(BaseModel):
"""할일 수정"""
content: Optional[str] = Field(None, min_length=1, max_length=2000)
photo_url: Optional[str] = Field(None, max_length=500, description="첨부 사진 URL")
status: Optional[str] = Field(None, pattern="^(draft|scheduled|active|completed|delayed)$")
start_date: Optional[datetime] = None
estimated_minutes: Optional[int] = Field(None, ge=1, le=120)
delayed_until: Optional[datetime] = None
class TodoItemDelay(BaseModel):
"""할일 지연"""
delayed_until: datetime
class TodoItemSplit(BaseModel):
"""할일 분할"""
subtasks: List[str] = Field(..., min_items=2, max_items=10)
estimated_minutes_per_task: List[int] = Field(..., min_items=2, max_items=10)
class TodoItemResponse(TodoItemBase):
id: UUID
user_id: UUID
photo_url: Optional[str] = None
status: str
created_at: datetime
start_date: Optional[datetime]
estimated_minutes: Optional[int]
completed_at: Optional[datetime]
delayed_until: Optional[datetime]
parent_id: Optional[UUID]
split_order: Optional[int]
# 댓글 수
comment_count: int = 0
class Config:
from_attributes = True
class TodoItemWithComments(TodoItemResponse):
"""댓글이 포함된 할일 응답"""
comments: List[TodoCommentResponse] = []
class TodoStats(BaseModel):
"""할일 통계"""
total_count: int
draft_count: int
scheduled_count: int
active_count: int
completed_count: int
delayed_count: int
completion_rate: float # 완료율 (%)
class TodoDashboard(BaseModel):
"""할일 대시보드"""
stats: TodoStats
today_todos: List[TodoItemResponse]
overdue_todos: List[TodoItemResponse]
upcoming_todos: List[TodoItemResponse]

View File

@@ -0,0 +1,12 @@
"""
서비스 레이어 모듈
"""
from .todo_service import TodoService
from .calendar_sync_service import CalendarSyncService, get_calendar_sync_service
__all__ = [
"TodoService",
"CalendarSyncService",
"get_calendar_sync_service"
]

View File

@@ -0,0 +1,74 @@
"""
캘린더 동기화 서비스
- 서비스 클래스 기준: 최대 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

@@ -0,0 +1,70 @@
import os
import base64
from datetime import datetime
from typing import Optional
import uuid
from PIL import Image
import io
UPLOAD_DIR = "/app/uploads"
def ensure_upload_dir():
"""업로드 디렉토리 생성"""
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
def save_base64_image(base64_string: str) -> Optional[str]:
"""Base64 이미지를 파일로 저장하고 경로 반환"""
try:
ensure_upload_dir()
# Base64 헤더 제거
if "," in base64_string:
base64_string = base64_string.split(",")[1]
# 디코딩
image_data = base64.b64decode(base64_string)
# 이미지 검증 및 형식 확인
image = Image.open(io.BytesIO(image_data))
# iPhone의 .mpo 파일이나 기타 형식을 JPEG로 강제 변환
# RGB 모드로 변환 (RGBA, P 모드 등을 처리)
if image.mode in ('RGBA', 'LA', 'P'):
# 투명도가 있는 이미지는 흰 배경과 합성
background = Image.new('RGB', image.size, (255, 255, 255))
if image.mode == 'P':
image = image.convert('RGBA')
background.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
image = background
elif image.mode != 'RGB':
image = image.convert('RGB')
# 파일명 생성 (강제로 .jpg)
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.jpg"
filepath = os.path.join(UPLOAD_DIR, filename)
# 이미지 저장 (최대 크기 제한)
max_size = (1920, 1920)
image.thumbnail(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 delete_file(filepath: str):
"""파일 삭제"""
try:
if filepath and filepath.startswith("/uploads/"):
filename = filepath.replace("/uploads/", "")
full_path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(full_path):
os.remove(full_path)
except Exception as e:
print(f"파일 삭제 실패: {e}")

View File

@@ -0,0 +1,300 @@
"""
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
)

56
docker-compose.yml Normal file
View File

@@ -0,0 +1,56 @@
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "4000:80"
depends_on:
- backend
environment:
- API_BASE_URL=http://localhost:9000/api
volumes:
- ./frontend/static:/usr/share/nginx/html/static
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "9000:9000"
depends_on:
- database
environment:
- DATABASE_URL=postgresql://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}
volumes:
- ./backend/src:/app/src
- ./backend/uploads:/app/uploads
- todo_uploads:/data/uploads
restart: unless-stopped
database:
image: postgres:15-alpine
ports:
- "5434:5432"
environment:
- POSTGRES_USER=${POSTGRES_USER:-todo_user}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-todo_password}
- POSTGRES_DB=${POSTGRES_DB:-todo_db}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
restart: unless-stopped
volumes:
postgres_data:
todo_uploads:

484
docs/API.md Normal file
View File

@@ -0,0 +1,484 @@
# API 문서 (API.md)
## 🚀 API 개요
Todo-Project REST API는 간결하고 직관적인 할일 관리 기능을 제공합니다.
### 기본 정보
- **Base URL**: `http://localhost:9000/api`
- **인증 방식**: JWT Bearer Token
- **응답 형식**: JSON
- **API 버전**: v1
### 포트 설정
- **Frontend**: http://localhost:4000
- **Backend API**: http://localhost:9000
- **Database**: localhost:5434
## 🔐 인증
### 로그인
```http
POST /api/auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "password123",
"remember_me": false
}
```
**응답:**
```json
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "bearer",
"expires_in": 1800
}
```
### 기기 등록 (개인용 최적화)
```http
POST /api/auth/register-device
Content-Type: application/json
Authorization: Bearer {access_token}
{
"device_name": " iPhone",
"fingerprint": "abc123def456",
"platform": "mobile"
}
```
**응답:**
```json
{
"device_token": "long-term-device-token-here",
"expires_at": "2024-02-15T10:30:00Z",
"device_id": "device-uuid"
}
```
### 기기 토큰 로그인
```http
POST /api/auth/device-login
Content-Type: application/json
{
"device_token": "long-term-device-token-here"
}
```
## 📋 할일 관리
### 할일 목록 조회
```http
GET /api/todos?status=active&limit=50&offset=0
Authorization: Bearer {access_token}
```
**쿼리 파라미터:**
- `status`: `draft`, `scheduled`, `active`, `completed`, `delayed`
- `limit`: 페이지당 항목 수 (기본: 50)
- `offset`: 시작 위치 (기본: 0)
**응답:**
```json
{
"todos": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "550e8400-e29b-41d4-a716-446655440001",
"content": "프로젝트 기획서 작성",
"status": "active",
"created_at": "2024-01-15T09:00:00Z",
"start_date": "2024-01-15T10:00:00Z",
"estimated_minutes": 120,
"completed_at": null,
"delayed_until": null,
"parent_id": null,
"split_order": null,
"comment_count": 2
}
],
"total": 1,
"has_more": false
}
```
### 할일 생성
```http
POST /api/todos
Content-Type: application/json
Authorization: Bearer {access_token}
{
"content": " "
}
```
**응답:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440002",
"user_id": "550e8400-e29b-41d4-a716-446655440001",
"content": "새로운 할일 내용",
"status": "draft",
"created_at": "2024-01-15T09:30:00Z",
"start_date": null,
"estimated_minutes": null,
"completed_at": null,
"delayed_until": null,
"parent_id": null,
"split_order": null,
"comment_count": 0
}
```
### 할일 일정 설정
```http
POST /api/todos/{todo_id}/schedule
Content-Type: application/json
Authorization: Bearer {access_token}
{
"start_date": "2024-01-16T14:00:00Z",
"estimated_minutes": 90
}
```
**응답:** 업데이트된 할일 객체
### 할일 완료 처리
```http
PUT /api/todos/{todo_id}/complete
Authorization: Bearer {access_token}
```
**응답:** 완료된 할일 객체 (status: "completed", completed_at 설정됨)
### 할일 지연 처리
```http
PUT /api/todos/{todo_id}/delay
Content-Type: application/json
Authorization: Bearer {access_token}
{
"delayed_until": "2024-01-17T10:00:00Z"
}
```
### 할일 분할
```http
POST /api/todos/{todo_id}/split
Content-Type: application/json
Authorization: Bearer {access_token}
{
"subtasks": [
"1: ",
"2: ",
"3: "
],
"estimated_minutes_per_task": [30, 60, 30]
}
```
**응답:** 생성된 하위 할일들의 배열
### 할일 상세 조회 (댓글 포함)
```http
GET /api/todos/{todo_id}
Authorization: Bearer {access_token}
```
**응답:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "550e8400-e29b-41d4-a716-446655440001",
"content": "프로젝트 기획서 작성",
"status": "active",
"created_at": "2024-01-15T09:00:00Z",
"start_date": "2024-01-15T10:00:00Z",
"estimated_minutes": 120,
"completed_at": null,
"delayed_until": null,
"parent_id": null,
"split_order": null,
"comment_count": 2,
"comments": [
{
"id": "550e8400-e29b-41d4-a716-446655440003",
"todo_item_id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "550e8400-e29b-41d4-a716-446655440001",
"content": "진행 상황 메모",
"created_at": "2024-01-15T11:00:00Z",
"updated_at": "2024-01-15T11:00:00Z"
}
]
}
```
## 💬 댓글/메모 관리
### 댓글 추가
```http
POST /api/todos/{todo_id}/comments
Content-Type: application/json
Authorization: Bearer {access_token}
{
"content": " "
}
```
### 댓글 목록 조회
```http
GET /api/todos/{todo_id}/comments
Authorization: Bearer {access_token}
```
## 📊 통계 및 대시보드
### 할일 통계
```http
GET /api/todos/stats
Authorization: Bearer {access_token}
```
**응답:**
```json
{
"total_count": 25,
"draft_count": 5,
"scheduled_count": 8,
"active_count": 7,
"completed_count": 4,
"delayed_count": 1,
"completion_rate": 16.0
}
```
### 활성 할일 조회 (시간 기반 자동 활성화)
```http
GET /api/todos/active
Authorization: Bearer {access_token}
```
**기능:** scheduled 상태의 할일 중 시작 시간이 지난 것들을 자동으로 active로 변경하고 반환
## 👤 사용자 관리
### 현재 사용자 정보
```http
GET /api/users/me
Authorization: Bearer {access_token}
```
**응답:**
```json
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"email": "user@example.com",
"full_name": "사용자 이름",
"is_active": true,
"is_admin": false,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-15T09:00:00Z",
"last_login_at": "2024-01-15T09:00:00Z",
"timezone": "Asia/Seoul",
"language": "ko"
}
```
### 사용자 정보 수정
```http
PUT /api/users/me
Content-Type: application/json
Authorization: Bearer {access_token}
{
"full_name": " ",
"timezone": "Asia/Seoul",
"language": "ko"
}
```
## 🔗 시놀로지 연동 (1단계)
### 시놀로지 설정 테스트
```http
POST /api/synology/test-connection
Content-Type: application/json
Authorization: Bearer {access_token}
{
"dsm_url": "https://your-nas.synology.me:5001",
"username": "todo_user",
"password": "password123"
}
```
**응답:**
```json
{
"dsm_connection": "success",
"calendar_connection": "success",
"mail_connection": "success",
"available_services": ["Calendar", "MailPlus"]
}
```
### 캘린더 동기화 수동 실행
```http
POST /api/synology/sync-calendar/{todo_id}
Authorization: Bearer {access_token}
```
### 메일 알림 발송
```http
POST /api/synology/send-notification/{todo_id}
Authorization: Bearer {access_token}
```
## 🔧 시스템 관리
### 헬스 체크
```http
GET /api/health
```
**응답:**
```json
{
"status": "healthy",
"timestamp": "2024-01-15T12:00:00Z",
"version": "0.1.0",
"database": "connected",
"synology_integration": "enabled"
}
```
### API 정보
```http
GET /api/info
```
**응답:**
```json
{
"name": "Todo Project API",
"version": "0.1.0",
"description": "간결하고 스마트한 개인용 할일 관리 시스템",
"docs_url": "/docs",
"redoc_url": "/redoc"
}
```
## 📝 오류 응답
### 표준 오류 형식
```json
{
"error": {
"code": "VALIDATION_ERROR",
"message": "입력 데이터가 유효하지 않습니다.",
"details": {
"field": "content",
"issue": "최소 1자 이상 입력해야 합니다."
}
},
"timestamp": "2024-01-15T12:00:00Z"
}
```
### 주요 오류 코드
- `AUTHENTICATION_ERROR`: 인증 실패
- `AUTHORIZATION_ERROR`: 권한 없음
- `VALIDATION_ERROR`: 입력 데이터 검증 실패
- `NOT_FOUND`: 리소스를 찾을 수 없음
- `CONFLICT`: 데이터 충돌
- `RATE_LIMIT_EXCEEDED`: 요청 한도 초과
- `SYNOLOGY_CONNECTION_ERROR`: 시놀로지 연동 오류
### HTTP 상태 코드
- `200`: 성공
- `201`: 생성 성공
- `400`: 잘못된 요청
- `401`: 인증 필요
- `403`: 권한 없음
- `404`: 찾을 수 없음
- `409`: 충돌
- `422`: 검증 실패
- `429`: 요청 한도 초과
- `500`: 서버 오류
## 🚀 SDK 및 클라이언트
### JavaScript 클라이언트 예제
```javascript
class TodoAPI {
constructor(baseURL = 'http://localhost:9000/api') {
this.baseURL = baseURL;
this.token = localStorage.getItem('access_token');
}
async createTodo(content) {
const response = await fetch(`${this.baseURL}/todos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`
},
body: JSON.stringify({ content })
});
return await response.json();
}
async getTodos(status = null) {
const params = status ? `?status=${status}` : '';
const response = await fetch(`${this.baseURL}/todos${params}`, {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
return await response.json();
}
async completeTodo(todoId) {
const response = await fetch(`${this.baseURL}/todos/${todoId}/complete`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${this.token}`
}
});
return await response.json();
}
}
// 사용 예제
const api = new TodoAPI();
// 할일 생성
const newTodo = await api.createTodo('새로운 할일');
// 할일 목록 조회
const activeTodos = await api.getTodos('active');
// 할일 완료
await api.completeTodo(newTodo.id);
```
## 📚 추가 리소스
- **Swagger UI**: http://localhost:9000/docs
- **ReDoc**: http://localhost:9000/redoc
- **OpenAPI Spec**: http://localhost:9000/openapi.json
이 API 문서를 통해 Todo-Project의 모든 기능을 활용할 수 있습니다!

469
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,469 @@
# 배포 가이드 (DEPLOYMENT.md)
## 🚀 배포 개요
Todo-Project는 Docker를 사용한 컨테이너 기반 배포를 지원하며, 개인용 환경에 최적화되어 있습니다.
## 📋 사전 요구사항
### 시스템 요구사항
- **OS**: Linux, macOS, Windows (Docker 지원)
- **RAM**: 최소 2GB, 권장 4GB
- **Storage**: 최소 10GB 여유 공간
- **Network**: 인터넷 연결 (시놀로지 연동 시)
### 필수 소프트웨어
- Docker 20.10+
- Docker Compose 2.0+
- Git (소스 코드 다운로드용)
## 🐳 Docker 배포
### 1. 소스 코드 다운로드
```bash
git clone https://github.com/your-username/Todo-Project.git
cd Todo-Project
```
### 2. 환경 설정
```bash
# 환경 변수 파일 생성
cp .env.example .env
# 환경 변수 편집
nano .env
```
#### 기본 환경 변수 설정
```bash
# 데이터베이스 설정
DATABASE_URL=postgresql://todo_user:todo_password@database:5432/todo_db
POSTGRES_USER=todo_user
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=todo_db
# JWT 설정 (반드시 변경!)
SECRET_KEY=your-very-long-and-random-secret-key-here-change-this-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# 애플리케이션 설정
DEBUG=false
CORS_ORIGINS=["http://localhost:4000", "http://your-domain.com:4000"]
# 서버 설정
HOST=0.0.0.0
PORT=9000
# 시놀로지 연동 (선택사항)
SYNOLOGY_DSM_URL=https://your-nas.synology.me:5001
SYNOLOGY_USERNAME=todo_user
SYNOLOGY_PASSWORD=your_synology_password
ENABLE_SYNOLOGY_INTEGRATION=true
```
### 3. Docker Compose 실행
```bash
# 백그라운드에서 실행
docker-compose up -d
# 로그 확인
docker-compose logs -f
```
### 4. 서비스 확인
```bash
# 컨테이너 상태 확인
docker-compose ps
# 헬스 체크
curl http://localhost:9000/api/health
# 프론트엔드 접속
open http://localhost:4000
```
## 🔧 Docker Compose 구성
### docker-compose.yml
```yaml
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "4000:80"
depends_on:
- backend
environment:
- API_BASE_URL=http://localhost:9000/api
volumes:
- ./frontend/static:/usr/share/nginx/html/static
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "9000:9000"
depends_on:
- database
environment:
- DATABASE_URL=postgresql://todo_user:${POSTGRES_PASSWORD}@database:5432/todo_db
- SECRET_KEY=${SECRET_KEY}
- DEBUG=${DEBUG:-false}
volumes:
- ./backend/uploads:/app/uploads
restart: unless-stopped
database:
image: postgres:15-alpine
ports:
- "5434:5432"
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
restart: unless-stopped
volumes:
postgres_data:
```
### 프로덕션용 docker-compose.prod.yml
```yaml
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
ports:
- "80:80"
- "443:443"
volumes:
- ./ssl:/etc/nginx/ssl
- ./nginx/nginx.prod.conf:/etc/nginx/nginx.conf
restart: always
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
expose:
- "9000"
environment:
- DEBUG=false
- DATABASE_URL=postgresql://todo_user:${POSTGRES_PASSWORD}@database:5432/todo_db
restart: always
database:
image: postgres:15-alpine
expose:
- "5432"
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backups:/backups
restart: always
volumes:
postgres_data:
```
## 🌐 프로덕션 배포
### 1. SSL 인증서 설정
```bash
# Let's Encrypt 인증서 생성 (Certbot 사용)
sudo certbot certonly --standalone -d your-domain.com
# 인증서 파일 복사
sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ./ssl/
sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem ./ssl/
```
### 2. Nginx 설정 (프로덕션)
```nginx
# nginx/nginx.prod.conf
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
# Frontend
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Backend 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;
}
# 보안 헤더
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
```
### 3. 프로덕션 배포 실행
```bash
# 프로덕션 환경으로 배포
docker-compose -f docker-compose.prod.yml up -d
# 로그 모니터링
docker-compose -f docker-compose.prod.yml logs -f
```
## 🔄 업데이트 및 유지보수
### 애플리케이션 업데이트
```bash
# 소스 코드 업데이트
git pull origin main
# 컨테이너 재빌드 및 재시작
docker-compose down
docker-compose build --no-cache
docker-compose up -d
```
### 데이터베이스 백업
```bash
# 백업 스크립트 생성
cat > backup.sh << 'EOF'
#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="todo_backup_${DATE}.sql"
docker-compose exec database pg_dump -U todo_user todo_db > ./backups/${BACKUP_FILE}
echo "백업 완료: ${BACKUP_FILE}"
# 7일 이상 된 백업 파일 삭제
find ./backups -name "todo_backup_*.sql" -mtime +7 -delete
EOF
chmod +x backup.sh
# 백업 실행
./backup.sh
```
### 데이터베이스 복원
```bash
# 백업에서 복원
docker-compose exec database psql -U todo_user -d todo_db < ./backups/todo_backup_20240115_120000.sql
```
### 로그 관리
```bash
# 로그 확인
docker-compose logs backend
docker-compose logs frontend
docker-compose logs database
# 로그 로테이션 설정
cat > /etc/logrotate.d/docker-compose << 'EOF'
/var/lib/docker/containers/*/*.log {
rotate 7
daily
compress
size=1M
missingok
delaycompress
copytruncate
}
EOF
```
## 📊 모니터링
### 헬스 체크 스크립트
```bash
#!/bin/bash
# health_check.sh
API_URL="http://localhost:9000/api/health"
FRONTEND_URL="http://localhost:4000"
# API 헬스 체크
if curl -f -s $API_URL > /dev/null; then
echo "✅ API 서버 정상"
else
echo "❌ API 서버 오류"
# 알림 발송 (예: 이메일, Slack 등)
fi
# 프론트엔드 체크
if curl -f -s $FRONTEND_URL > /dev/null; then
echo "✅ 프론트엔드 정상"
else
echo "❌ 프론트엔드 오류"
fi
# 데이터베이스 체크
if docker-compose exec database pg_isready -U todo_user > /dev/null; then
echo "✅ 데이터베이스 정상"
else
echo "❌ 데이터베이스 오류"
fi
```
### 시스템 리소스 모니터링
```bash
# 컨테이너 리소스 사용량 확인
docker stats
# 디스크 사용량 확인
df -h
# 메모리 사용량 확인
free -h
```
## 🔐 보안 설정
### 방화벽 설정 (Ubuntu/CentOS)
```bash
# UFW (Ubuntu)
sudo ufw allow 22/tcp # SSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
# firewalld (CentOS)
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
```
### 자동 보안 업데이트
```bash
# Ubuntu
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
# CentOS
sudo yum install yum-cron
sudo systemctl enable yum-cron
sudo systemctl start yum-cron
```
## 🚨 문제 해결
### 일반적인 문제들
#### 1. 컨테이너 시작 실패
```bash
# 로그 확인
docker-compose logs backend
# 포트 충돌 확인
netstat -tulpn | grep :9000
# 권한 문제 확인
ls -la ./backend/uploads
```
#### 2. 데이터베이스 연결 실패
```bash
# 데이터베이스 컨테이너 상태 확인
docker-compose exec database pg_isready -U todo_user
# 연결 테스트
docker-compose exec database psql -U todo_user -d todo_db -c "SELECT 1;"
```
#### 3. 시놀로지 연동 문제
```bash
# 네트워크 연결 테스트
curl -k https://your-nas.synology.me:5001/webapi/auth.cgi
# DNS 해결 확인
nslookup your-nas.synology.me
```
### 성능 최적화
#### 1. 데이터베이스 최적화
```sql
-- 인덱스 확인
SELECT schemaname, tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'todo_items';
-- 쿼리 성능 분석
EXPLAIN ANALYZE SELECT * FROM todo_items WHERE user_id = 'uuid';
```
#### 2. 캐싱 설정
```bash
# Redis 추가 (선택사항)
docker run -d --name redis -p 6379:6379 redis:alpine
```
## 📱 모바일 PWA 배포
### PWA 설정 확인
```javascript
// manifest.json 검증
{
"name": "Todo Project",
"short_name": "Todo",
"start_url": "/",
"display": "standalone",
"background_color": "#6366f1",
"theme_color": "#6366f1",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
```
### Service Worker 등록
```javascript
// sw.js 등록 확인
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
```
이 배포 가이드를 통해 안정적이고 확장 가능한 Todo-Project를 배포할 수 있습니다!

387
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,387 @@
# 보안 가이드 (SECURITY.md)
## 🔐 보안 철학
Todo-Project는 **개인용 도구**로 설계되어 **편의성**과 **적절한 보안** 사이의 균형을 추구합니다.
### 보안 원칙
- **Trust but Verify**: 신뢰할 수 있는 기기에서는 간편하게, 의심스러운 접근은 차단
- **최소 권한**: 필요한 최소한의 권한만 부여
- **개인 최적화**: 개인 사용에 최적화된 보안 모델
## 🛡️ 보안 레벨
### 1. Minimal (개인용 권장)
```python
SECURITY_MINIMAL = {
"device_remember_days": 30, # 30일간 기기 기억
"require_password": False, # 기기 등록 후 비밀번호 불필요
"session_timeout": 0, # 무제한 세션
"biometric_optional": True, # 생체 인증 선택사항
"auto_login": True # 자동 로그인 활성화
}
```
**적합한 환경**: 개인 기기 (내 폰, 내 컴퓨터)에서만 사용
### 2. Balanced (일반 권장)
```python
SECURITY_BALANCED = {
"device_remember_days": 7, # 7일간 기기 기억
"require_password": True, # 주기적 비밀번호 확인
"session_timeout": 24*60, # 24시간 세션
"biometric_optional": True, # 생체 인증 선택사항
"auto_login": False # 수동 로그인
}
```
**적합한 환경**: 가끔 다른 기기에서도 접근하는 경우
### 3. Secure (높은 보안)
```python
SECURITY_SECURE = {
"device_remember_days": 1, # 1일간만 기기 기억
"require_password": True, # 매번 비밀번호 확인
"session_timeout": 60, # 1시간 세션
"biometric_required": True, # 생체 인증 필수
"auto_login": False # 수동 로그인
}
```
**적합한 환경**: 민감한 정보가 포함된 경우
## 🔑 인증 시스템
### 기기 등록 방식
#### 기기 식별
```python
class DeviceFingerprint:
"""기기 고유 식별자 생성"""
def generate_fingerprint(self, request):
"""브라우저 fingerprint 생성"""
components = [
request.headers.get('User-Agent', ''),
request.headers.get('Accept-Language', ''),
request.headers.get('Accept-Encoding', ''),
self.get_screen_resolution(), # JavaScript에서 전송
self.get_timezone(), # JavaScript에서 전송
self.get_platform_info() # JavaScript에서 전송
]
fingerprint = hashlib.sha256(
'|'.join(components).encode('utf-8')
).hexdigest()
return fingerprint[:16] # 16자리 축약
```
#### 기기 등록 프로세스
```python
class DeviceRegistration:
"""기기 등록 관리"""
async def register_device(self, user_id, device_info, user_confirmation):
"""새 기기 등록"""
# 1. 사용자 확인 (비밀번호 또는 기존 기기에서 승인)
if not await self.verify_user_identity(user_id, user_confirmation):
raise AuthenticationError("사용자 확인 실패")
# 2. 기기 정보 생성
device_id = self.generate_device_id(device_info)
device_name = device_info.get('name', '알 수 없는 기기')
# 3. 장기 토큰 생성 (30일 유효)
device_token = self.create_device_token(user_id, device_id)
# 4. 기기 정보 저장
device_record = {
"device_id": device_id,
"user_id": user_id,
"device_name": device_name,
"fingerprint": device_info['fingerprint'],
"registered_at": datetime.now(),
"last_used": datetime.now(),
"token": device_token,
"expires_at": datetime.now() + timedelta(days=30),
"is_trusted": True
}
await self.save_device_record(device_record)
return device_token
```
### 토큰 관리
#### JWT 토큰 구조
```python
class TokenManager:
"""토큰 생성 및 관리"""
def create_device_token(self, user_id, device_id):
"""장기간 유효한 기기 토큰 생성"""
payload = {
"user_id": str(user_id),
"device_id": device_id,
"token_type": "device",
"issued_at": datetime.utcnow(),
"expires_at": datetime.utcnow() + timedelta(days=30)
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def create_session_token(self, user_id, device_id):
"""세션 토큰 생성"""
payload = {
"user_id": str(user_id),
"device_id": device_id,
"token_type": "session",
"issued_at": datetime.utcnow(),
"expires_at": datetime.utcnow() + timedelta(hours=24)
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
```
#### 토큰 검증
```python
class TokenValidator:
"""토큰 검증"""
async def validate_device_token(self, token):
"""기기 토큰 검증"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
# 토큰 타입 확인
if payload.get("token_type") != "device":
return None
# 만료 시간 확인
expires_at = datetime.fromisoformat(payload["expires_at"])
if datetime.utcnow() > expires_at:
return None
# 기기 정보 확인
device_record = await self.get_device_record(
payload["user_id"],
payload["device_id"]
)
if not device_record or not device_record["is_trusted"]:
return None
return payload
except jwt.JWTError:
return None
```
## 🔒 데이터 보안
### 데이터베이스 보안
#### 비밀번호 해싱
```python
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""비밀번호 해싱 (bcrypt)"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""비밀번호 검증"""
return pwd_context.verify(plain_password, hashed_password)
```
#### 민감 정보 암호화
```python
from cryptography.fernet import Fernet
class DataEncryption:
"""민감 정보 암호화"""
def __init__(self):
self.key = settings.ENCRYPTION_KEY.encode()
self.cipher = Fernet(self.key)
def encrypt_sensitive_data(self, data: str) -> str:
"""민감 정보 암호화 (시놀로지 비밀번호 등)"""
return self.cipher.encrypt(data.encode()).decode()
def decrypt_sensitive_data(self, encrypted_data: str) -> str:
"""민감 정보 복호화"""
return self.cipher.decrypt(encrypted_data.encode()).decode()
```
### 네트워크 보안
#### HTTPS 강제
```python
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
# 프로덕션에서 HTTPS 강제
if not settings.DEBUG:
app.add_middleware(HTTPSRedirectMiddleware)
```
#### CORS 설정
```python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS, # 특정 도메인만 허용
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["*"],
)
```
## 🚨 보안 모니터링
### 로그인 시도 모니터링
```python
class SecurityMonitor:
"""보안 모니터링"""
def __init__(self):
self.failed_attempts = {} # IP별 실패 횟수
self.blocked_ips = set() # 차단된 IP
async def record_login_attempt(self, ip_address, success):
"""로그인 시도 기록"""
if success:
# 성공 시 실패 횟수 초기화
self.failed_attempts.pop(ip_address, None)
else:
# 실패 시 횟수 증가
self.failed_attempts[ip_address] = \
self.failed_attempts.get(ip_address, 0) + 1
# 5회 실패 시 30분 차단
if self.failed_attempts[ip_address] >= 5:
self.block_ip(ip_address, minutes=30)
def block_ip(self, ip_address, minutes=30):
"""IP 주소 차단"""
self.blocked_ips.add(ip_address)
# 일정 시간 후 차단 해제
asyncio.create_task(
self.unblock_ip_after(ip_address, minutes)
)
```
### 의심스러운 활동 감지
```python
class AnomalyDetection:
"""이상 활동 감지"""
async def detect_suspicious_activity(self, user_id, activity):
"""의심스러운 활동 감지"""
# 1. 비정상적인 시간대 접근
if self.is_unusual_time(activity.timestamp):
await self.alert_unusual_time_access(user_id, activity)
# 2. 새로운 기기에서 접근
if not await self.is_known_device(user_id, activity.device_info):
await self.alert_new_device_access(user_id, activity)
# 3. 비정상적인 API 호출 패턴
if await self.is_unusual_api_pattern(user_id, activity):
await self.alert_unusual_api_pattern(user_id, activity)
```
## 🔧 보안 설정
### 환경 변수 보안
```bash
# .env 파일 보안 설정
SECRET_KEY=your-very-long-and-random-secret-key-here
ENCRYPTION_KEY=your-32-byte-encryption-key-here
# 시놀로지 인증 정보 (암호화 저장)
SYNOLOGY_USERNAME=encrypted_username
SYNOLOGY_PASSWORD=encrypted_password
# 보안 레벨 설정
SECURITY_LEVEL=minimal # minimal, balanced, secure
ENABLE_DEVICE_REGISTRATION=true
ENABLE_BIOMETRIC_AUTH=true
ENABLE_SECURITY_MONITORING=true
# 세션 설정
SESSION_TIMEOUT_MINUTES=1440 # 24시간
DEVICE_REMEMBER_DAYS=30
```
### 보안 헤더
```python
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import Response
# 신뢰할 수 있는 호스트만 허용
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["localhost", "127.0.0.1", "your-domain.com"]
)
@app.middleware("http")
async def add_security_headers(request, call_next):
"""보안 헤더 추가"""
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
```
## 🛠️ 보안 체크리스트
### 개발 환경
- [ ] `.env` 파일이 `.gitignore`에 포함되어 있는가?
- [ ] 기본 비밀번호가 변경되었는가?
- [ ] 디버그 모드가 비활성화되어 있는가? (프로덕션)
- [ ] 로그에 민감 정보가 포함되지 않는가?
### 인증 시스템
- [ ] 비밀번호가 안전하게 해싱되어 있는가?
- [ ] JWT 토큰에 민감 정보가 포함되지 않는가?
- [ ] 토큰 만료 시간이 적절하게 설정되어 있는가?
- [ ] 기기 등록 프로세스가 안전한가?
### 네트워크 보안
- [ ] HTTPS가 활성화되어 있는가? (프로덕션)
- [ ] CORS 설정이 적절한가?
- [ ] 보안 헤더가 설정되어 있는가?
- [ ] 불필요한 포트가 차단되어 있는가?
### 데이터 보안
- [ ] 민감 정보가 암호화되어 있는가?
- [ ] 데이터베이스 접근이 제한되어 있는가?
- [ ] 백업 데이터가 안전하게 보관되어 있는가?
- [ ] 로그 파일이 안전하게 관리되어 있는가?
## 🚨 보안 사고 대응
### 사고 대응 절차
1. **즉시 조치**: 의심스러운 접근 차단
2. **영향 평가**: 피해 범위 확인
3. **복구 작업**: 시스템 정상화
4. **사후 분석**: 원인 분석 및 재발 방지
### 비상 연락처
- **시스템 관리자**: [연락처]
- **보안 담당자**: [연락처]
- **시놀로지 지원**: [연락처]
이 보안 가이드를 통해 안전하고 편리한 Todo 시스템을 구축할 수 있습니다.

400
frontend/calendar.html Normal file
View File

@@ -0,0 +1,400 @@
<!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';
}
// 캘린더 항목 로드
function loadCalendarItems() {
// 임시 데이터 (실제로는 API에서 가져옴)
const calendarItems = [
{
id: 1,
content: '월말 보고서 제출',
photo: null,
due_date: '2024-01-25',
status: 'active',
priority: 'urgent',
created_at: '2024-01-15'
},
{
id: 2,
content: '클라이언트 미팅 자료 준비',
photo: null,
due_date: '2024-01-30',
status: 'active',
priority: 'warning',
created_at: '2024-01-16'
}
];
renderCalendarItems(calendarItems);
}
// 캘린더 항목 렌더링
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.photo ? `
<div class="flex-shrink-0">
<img src="${item.photo}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<h4 class="text-gray-900 font-medium mb-2">${item.content}</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) {
const date = new Date(dateString);
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>
</body>
</html>

413
frontend/checklist.html Normal file
View File

@@ -0,0 +1,413 @@
<!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';
}
// 체크리스트 항목 로드
function loadChecklistItems() {
// 임시 데이터 (실제로는 API에서 가져옴)
checklistItems = [
{
id: 1,
content: '책상 정리하기',
photo: null,
completed: false,
created_at: '2024-01-15',
completed_at: null
},
{
id: 2,
content: '운동 계획 세우기',
photo: null,
completed: true,
created_at: '2024-01-16',
completed_at: '2024-01-18'
},
{
id: 3,
content: '독서 목록 만들기',
photo: null,
completed: false,
created_at: '2024-01-17',
completed_at: null
}
];
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.photo ? `
<div class="flex-shrink-0">
<img src="${item.photo}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<h4 class="item-content text-gray-900 font-medium mb-2">${item.content}</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">
<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) {
item.completed = !item.completed;
item.completed_at = item.completed ? new Date().toISOString().split('T')[0] : null;
renderChecklistItems(checklistItems);
updateProgress();
// TODO: API 호출하여 상태 업데이트
console.log('체크리스트 완료 상태 변경:', id, item.completed);
}
}
// 진행률 업데이트
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) {
const date = new Date(dateString);
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 goToDashboard() {
window.location.href = 'dashboard.html';
}
// 전역 함수 등록
window.goToDashboard = goToDashboard;
</script>
</body>
</html>

652
frontend/classify.html Normal file
View File

@@ -0,0 +1,652 @@
<!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>
<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">분류 센터</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/auth.js"></script>
<script>
let pendingItems = [];
let selectedItems = [];
let currentFilter = 'all';
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
checkAuthStatus();
loadPendingItems();
updateStats();
});
// 분류 대기 항목 로드
function loadPendingItems() {
// 임시 데이터
pendingItems = [
{
id: 1,
type: 'upload',
content: '회의실 화이트보드 사진',
photo: '/static/images/sample1.jpg',
created_at: '2024-01-20T10:30:00Z',
source: '직접 업로드',
suggested: 'todo',
confidence: 0.85,
tags: ['업무', '회의', '계획']
},
{
id: 2,
type: 'mail',
content: '긴급: 내일까지 월말 보고서 제출 요청',
sender: 'manager@company.com',
created_at: '2024-01-20T14:15:00Z',
source: '시놀로지 메일플러스',
suggested: 'calendar',
confidence: 0.95,
tags: ['긴급', '업무', '마감']
},
{
id: 3,
type: 'upload',
content: '마트에서 살 것들 메모',
photo: '/static/images/sample2.jpg',
created_at: '2024-01-20T16:45:00Z',
source: '직접 업로드',
suggested: 'checklist',
confidence: 0.90,
tags: ['개인', '쇼핑', '생활']
},
{
id: 4,
type: 'mail',
content: '프로젝트 킥오프 미팅 일정 조율',
sender: 'team@company.com',
created_at: '2024-01-20T09:20:00Z',
source: '시놀로지 메일플러스',
suggested: 'todo',
confidence: 0.75,
tags: ['업무', '미팅', '프로젝트']
}
];
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.photo ? `
<div class="flex-shrink-0">
<img src="${item.photo}" class="w-20 h-20 object-cover rounded-lg" alt="첨부 사진">
</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) {
const date = new Date(dateString);
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>

937
frontend/dashboard.html Normal file
View File

@@ -0,0 +1,937 @@
<!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>
<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);
}
/* 캘린더 스타일 */
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background-color: var(--gray-200);
border-radius: 0.5rem;
overflow: hidden;
}
.calendar-day {
background-color: white;
min-height: 120px;
padding: 8px;
position: relative;
transition: all 0.2s;
}
.calendar-day:hover {
background-color: var(--gray-50);
}
.calendar-day.other-month {
background-color: #fafafa;
color: #9ca3af;
}
.calendar-day.today {
background-color: #eff6ff;
border: 2px solid var(--primary);
}
.calendar-header {
background-color: var(--gray-100);
padding: 12px 8px;
text-align: center;
font-weight: 600;
color: var(--gray-700);
}
.day-number {
font-weight: 600;
margin-bottom: 4px;
}
.day-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.day-item {
font-size: 10px;
padding: 2px 4px;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.day-item.todo {
background-color: #dbeafe;
color: #1e40af;
border-left: 3px solid var(--primary);
}
.day-item.calendar {
background-color: #fef3c7;
color: #92400e;
border-left: 3px solid var(--warning);
}
/* 모바일 일일 뷰 */
.daily-view {
display: none;
}
.daily-item {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
margin-bottom: 12px;
}
.daily-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.time-indicator {
width: 4px;
height: 100%;
border-radius: 2px;
}
.time-indicator.todo {
background-color: var(--primary);
}
.time-indicator.calendar {
background-color: var(--warning);
}
/* 체크리스트 스타일 */
.checklist-item {
background: white;
border-radius: 0.5rem;
padding: 12px;
margin-bottom: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.checklist-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.checklist-item.completed {
opacity: 0.6;
background-color: #f9fafb;
}
.checkbox-custom {
width: 18px;
height: 18px;
border: 2px solid #d1d5db;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.checkbox-custom.checked {
background-color: var(--success);
border-color: var(--success);
color: white;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.desktop-view {
display: none !important;
}
.daily-view {
display: block !important;
}
.calendar-day {
min-height: 80px;
padding: 4px;
}
.day-item {
font-size: 9px;
padding: 1px 3px;
}
/* 모바일 업로드 모달 */
.desktop-upload {
display: none !important;
}
.mobile-upload {
display: block !important;
}
}
@media (min-width: 769px) {
/* 데스크톱 업로드 모달 */
.mobile-upload {
display: none !important;
}
.desktop-upload {
display: block !important;
}
}
@media (max-width: 640px) {
.calendar-day {
min-height: 60px;
padding: 2px;
}
.day-number {
font-size: 12px;
}
}
</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-chart-line text-2xl text-blue-500 mr-3"></i>
<h1 class="text-xl font-semibold text-gray-800">대시보드</h1>
<span class="ml-3 text-sm text-gray-500" id="currentDate"></span>
</div>
<div class="flex items-center space-x-4">
<button onclick="openUploadModal()" class="btn-primary px-4 py-2 rounded-lg text-sm">
<i class="fas fa-plus mr-1"></i>새 항목
</button>
<button onclick="goToClassify()" class="text-purple-600 hover:text-purple-800 font-medium text-sm">
<i class="fas fa-inbox mr-1"></i>분류 센터
<span class="ml-1 px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full">3</span>
</button>
<button onclick="goToToday()" class="text-sm text-blue-600 hover:text-blue-800">
<i class="fas fa-calendar-day 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="desktop-view">
<!-- 캘린더 네비게이션 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<button onclick="previousMonth()" class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg">
<i class="fas fa-chevron-left"></i>
</button>
<h2 class="text-2xl font-bold text-gray-800" id="currentMonth"></h2>
<button onclick="nextMonth()" class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2 text-sm">
<div class="w-3 h-3 bg-blue-200 border-l-4 border-blue-500 rounded-sm"></div>
<span class="text-gray-600">Todo</span>
</div>
<div class="flex items-center space-x-2 text-sm">
<div class="w-3 h-3 bg-yellow-200 border-l-4 border-yellow-500 rounded-sm"></div>
<span class="text-gray-600">캘린더</span>
</div>
</div>
</div>
</div>
<!-- 캘린더 그리드 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-8">
<div class="calendar-grid">
<!-- 요일 헤더 -->
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<div class="calendar-header"></div>
<!-- 캘린더 날짜들 -->
<div id="calendarDays"></div>
</div>
</div>
<!-- 체크리스트 섹션 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-check-square text-green-500 mr-2"></i>체크리스트
</h3>
<div class="text-sm text-gray-600">
<span id="checklistProgress">0/0 완료</span>
</div>
</div>
<div id="checklistItems" class="max-h-96 overflow-y-auto">
<!-- 체크리스트 항목들이 여기에 추가됩니다 -->
</div>
</div>
</div>
<!-- 모바일 뷰 -->
<div class="daily-view">
<!-- 오늘 날짜 -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-800" id="todayDate"></h2>
<p class="text-gray-600" id="todayWeekday"></p>
</div>
</div>
<!-- 오늘의 일정 -->
<div class="mb-8">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-calendar-day text-blue-500 mr-2"></i>오늘의 일정
</h3>
<div id="todayItems">
<!-- 오늘의 항목들이 여기에 추가됩니다 -->
</div>
</div>
<!-- 모바일 체크리스트 -->
<div class="bg-white rounded-xl shadow-sm p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-check-square text-green-500 mr-2"></i>체크리스트
</h3>
<div class="text-sm text-gray-600">
<span id="mobileChecklistProgress">0/0</span>
</div>
</div>
<div id="mobileChecklistItems">
<!-- 모바일 체크리스트 항목들 -->
</div>
</div>
</div>
</main>
</div>
<!-- 업로드 모달 -->
<div id="uploadModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<!-- 모달 헤더 -->
<div class="flex justify-between items-center p-6 border-b">
<h3 class="text-lg font-semibold text-gray-800">
<i class="fas fa-plus-circle text-blue-500 mr-2"></i>새 항목 등록
</h3>
<button onclick="closeUploadModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 모달 내용 -->
<div class="p-6">
<form id="uploadForm" class="space-y-4">
<!-- 메모 입력 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">메모</label>
<input type="text" id="uploadContent" class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="메모를 입력하세요..." required>
</div>
<!-- 사진 업로드 영역 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">사진 (선택사항)</label>
<!-- 데스크톱용 파일 선택 -->
<div class="desktop-upload">
<div class="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center hover:border-blue-300 transition-colors">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-3"></i>
<p class="text-gray-600 mb-2">파일을 선택하거나 드래그하여 업로드</p>
<button type="button" onclick="selectFile()" class="text-blue-600 hover:text-blue-800 font-medium">
파일 선택
</button>
<input type="file" id="desktopFileInput" accept="image/*" class="hidden">
</div>
</div>
<!-- 모바일용 카메라/갤러리 선택 -->
<div class="mobile-upload hidden">
<div class="grid grid-cols-2 gap-3">
<button type="button" onclick="openCamera()" class="border-2 border-dashed border-gray-200 rounded-lg p-4 text-center hover:border-blue-300 transition-colors">
<i class="fas fa-camera text-2xl text-gray-400 mb-2"></i>
<p class="text-sm text-gray-600">카메라</p>
</button>
<button type="button" onclick="openGallery()" class="border-2 border-dashed border-gray-200 rounded-lg p-4 text-center hover:border-blue-300 transition-colors">
<i class="fas fa-images text-2xl text-gray-400 mb-2"></i>
<p class="text-sm text-gray-600">갤러리</p>
</button>
</div>
<input type="file" id="cameraInput" accept="image/*" capture="camera" class="hidden">
<input type="file" id="galleryInput" accept="image/*" class="hidden">
</div>
<!-- 사진 미리보기 -->
<div id="photoPreview" class="hidden mt-4">
<div class="relative">
<img id="previewImage" class="w-full h-48 object-cover rounded-lg" alt="미리보기">
<button type="button" onclick="removePhoto()" class="absolute top-2 right-2 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600">
<i class="fas fa-times text-sm"></i>
</button>
</div>
<div class="mt-2 text-sm text-gray-600" id="photoInfo"></div>
</div>
</div>
<!-- 버튼 -->
<div class="flex space-x-3 pt-4">
<button type="submit" class="btn-primary flex-1 py-3 px-4 rounded-lg font-medium">
<i class="fas fa-plus mr-2"></i>등록하기
</button>
<button type="button" onclick="closeUploadModal()" class="px-4 py-3 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
취소
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60">
<div class="bg-white rounded-lg p-6 flex items-center space-x-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-gray-700">처리 중...</span>
</div>
</div>
<!-- JavaScript -->
<script src="static/js/auth.js"></script>
<script src="static/js/image-utils.js"></script>
<script>
let currentDate = new Date();
let calendarData = {};
let checklistData = [];
// 페이지 초기화
document.addEventListener('DOMContentLoaded', () => {
checkAuthStatus();
initializeDashboard();
});
// 대시보드 초기화
function initializeDashboard() {
updateCurrentDate();
loadCalendarData();
loadChecklistData();
renderCalendar();
renderDailyView();
renderChecklist();
}
// 현재 날짜 업데이트
function updateCurrentDate() {
const now = new Date();
const options = { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' };
document.getElementById('currentDate').textContent = now.toLocaleDateString('ko-KR', {
month: 'long',
day: 'numeric'
});
document.getElementById('currentMonth').textContent = currentDate.toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long'
});
document.getElementById('todayDate').textContent = now.toLocaleDateString('ko-KR', {
month: 'long',
day: 'numeric'
});
document.getElementById('todayWeekday').textContent = now.toLocaleDateString('ko-KR', {
weekday: 'long'
});
}
// 캘린더 데이터 로드
function loadCalendarData() {
// 임시 데이터
calendarData = {
'2024-01-20': [
{ type: 'todo', title: '프로젝트 시작', time: '09:00' },
{ type: 'calendar', title: '회의 준비', time: '14:00' }
],
'2024-01-22': [
{ type: 'calendar', title: '보고서 제출', time: '17:00' }
],
'2024-01-25': [
{ type: 'todo', title: '문서 검토', time: '10:00' },
{ type: 'todo', title: '팀 미팅', time: '15:00' },
{ type: 'calendar', title: '월말 마감', time: '18:00' }
]
};
}
// 체크리스트 데이터 로드
function loadChecklistData() {
checklistData = [
{ id: 1, title: '책상 정리하기', completed: false },
{ id: 2, title: '운동 계획 세우기', completed: true },
{ id: 3, title: '독서 목록 만들기', completed: false },
{ id: 4, title: '이메일 정리', completed: false },
{ id: 5, title: '비타민 구매', completed: true }
];
}
// 캘린더 렌더링
function renderCalendar() {
const calendarDays = document.getElementById('calendarDays');
if (!calendarDays) return;
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
// 월의 첫 번째 날과 마지막 날
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// 캘린더 시작일 (이전 달의 마지막 주 포함)
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - firstDay.getDay());
// 캘린더 종료일 (다음 달의 첫 주 포함)
const endDate = new Date(lastDay);
endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()));
let html = '';
const today = new Date();
for (let date = new Date(startDate); date <= endDate; date.setDate(date.getDate() + 1)) {
const dateStr = date.toISOString().split('T')[0];
const isCurrentMonth = date.getMonth() === month;
const isToday = date.toDateString() === today.toDateString();
const dayData = calendarData[dateStr] || [];
html += `
<div class="calendar-day ${!isCurrentMonth ? 'other-month' : ''} ${isToday ? 'today' : ''}"
onclick="selectDate('${dateStr}')">
<div class="day-number">${date.getDate()}</div>
<div class="day-items">
${dayData.map(item => `
<div class="day-item ${item.type}" title="${item.title} (${item.time})">
${item.title}
</div>
`).join('')}
</div>
</div>
`;
}
calendarDays.innerHTML = html;
}
// 일일 뷰 렌더링 (모바일)
function renderDailyView() {
const todayItems = document.getElementById('todayItems');
if (!todayItems) return;
const today = new Date().toISOString().split('T')[0];
const todayData = calendarData[today] || [];
if (todayData.length === 0) {
todayItems.innerHTML = `
<div class="text-center py-8 text-gray-500">
<i class="fas fa-calendar-day text-3xl mb-3 opacity-50"></i>
<p>오늘 예정된 일정이 없습니다.</p>
</div>
`;
return;
}
todayItems.innerHTML = todayData.map(item => `
<div class="daily-item p-4">
<div class="flex items-center space-x-3">
<div class="time-indicator ${item.type}"></div>
<div class="flex-1">
<h4 class="font-medium text-gray-900">${item.title}</h4>
<p class="text-sm text-gray-600">
<i class="fas fa-clock mr-1"></i>${item.time}
</p>
</div>
<div class="text-xs px-2 py-1 rounded-full ${item.type === 'todo' ? 'bg-blue-100 text-blue-800' : 'bg-yellow-100 text-yellow-800'}">
${item.type === 'todo' ? 'Todo' : '캘린더'}
</div>
</div>
</div>
`).join('');
}
// 체크리스트 렌더링
function renderChecklist() {
const checklistItems = document.getElementById('checklistItems');
const mobileChecklistItems = document.getElementById('mobileChecklistItems');
const checklistProgress = document.getElementById('checklistProgress');
const mobileChecklistProgress = document.getElementById('mobileChecklistProgress');
const completed = checklistData.filter(item => item.completed).length;
const total = checklistData.length;
const progressText = `${completed}/${total} 완료`;
if (checklistProgress) checklistProgress.textContent = progressText;
if (mobileChecklistProgress) mobileChecklistProgress.textContent = `${completed}/${total}`;
const html = checklistData.map(item => `
<div class="checklist-item ${item.completed ? 'completed' : ''}">
<div class="flex items-center space-x-3">
<div class="checkbox-custom ${item.completed ? 'checked' : ''}"
onclick="toggleChecklistItem(${item.id})">
${item.completed ? '<i class="fas fa-check text-xs"></i>' : ''}
</div>
<span class="flex-1 ${item.completed ? 'line-through text-gray-500' : 'text-gray-900'}">${item.title}</span>
</div>
</div>
`).join('');
if (checklistItems) checklistItems.innerHTML = html;
if (mobileChecklistItems) mobileChecklistItems.innerHTML = html;
}
// 체크리스트 항목 토글
function toggleChecklistItem(id) {
const item = checklistData.find(item => item.id === id);
if (item) {
item.completed = !item.completed;
renderChecklist();
// TODO: API 호출
}
}
// 이전 달
function previousMonth() {
currentDate.setMonth(currentDate.getMonth() - 1);
updateCurrentDate();
renderCalendar();
}
// 다음 달
function nextMonth() {
currentDate.setMonth(currentDate.getMonth() + 1);
updateCurrentDate();
renderCalendar();
}
// 오늘로 이동
function goToToday() {
currentDate = new Date();
updateCurrentDate();
renderCalendar();
renderDailyView();
}
// 날짜 선택
function selectDate(dateStr) {
console.log('선택된 날짜:', dateStr);
// TODO: 선택된 날짜의 상세 정보 표시
}
// 뒤로 가기
function goBack() {
window.location.href = 'index.html';
}
// 분류 센터로 이동
function goToClassify() {
window.location.href = 'classify.html';
}
// 업로드 모달 관련 변수
let currentPhoto = null;
// 업로드 모달 열기
function openUploadModal() {
document.getElementById('uploadModal').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
// 업로드 모달 닫기
function closeUploadModal() {
document.getElementById('uploadModal').classList.add('hidden');
document.body.style.overflow = 'auto';
clearUploadForm();
}
// 업로드 폼 초기화
function clearUploadForm() {
document.getElementById('uploadForm').reset();
removePhoto();
}
// 파일 선택 (데스크톱)
function selectFile() {
document.getElementById('desktopFileInput').click();
}
// 카메라 열기 (모바일)
function openCamera() {
document.getElementById('cameraInput').click();
}
// 갤러리 열기 (모바일)
function openGallery() {
document.getElementById('galleryInput').click();
}
// 사진 제거
function removePhoto() {
currentPhoto = null;
const previewContainer = document.getElementById('photoPreview');
const previewImage = document.getElementById('previewImage');
if (previewContainer) {
previewContainer.classList.add('hidden');
}
if (previewImage) {
previewImage.src = '';
}
// 파일 입력 초기화
const inputs = ['desktopFileInput', 'cameraInput', 'galleryInput'];
inputs.forEach(id => {
const input = document.getElementById(id);
if (input) input.value = '';
});
}
// 사진 업로드 처리
async function handlePhotoUpload(event) {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
try {
showLoading(true);
// 이미지 압축 (ImageUtils가 있는 경우)
let processedImage;
if (window.ImageUtils) {
processedImage = await ImageUtils.compressImage(file, {
maxWidth: 800,
maxHeight: 600,
quality: 0.8
});
} else {
// 기본 처리
processedImage = await fileToBase64(file);
}
currentPhoto = processedImage;
// 미리보기 표시
const previewContainer = document.getElementById('photoPreview');
const previewImage = document.getElementById('previewImage');
const photoInfo = document.getElementById('photoInfo');
if (previewContainer && previewImage) {
previewImage.src = processedImage;
previewContainer.classList.remove('hidden');
}
if (photoInfo) {
const fileSize = Math.round(file.size / 1024);
photoInfo.textContent = `${file.name} (${fileSize}KB)`;
}
} catch (error) {
console.error('이미지 처리 실패:', error);
alert('이미지 처리에 실패했습니다.');
} finally {
showLoading(false);
}
}
// 파일을 Base64로 변환
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// 업로드 폼 제출
async function handleUploadSubmit(event) {
event.preventDefault();
const content = document.getElementById('uploadContent').value.trim();
if (!content) {
alert('메모를 입력해주세요.');
return;
}
try {
showLoading(true);
const itemData = {
content: content,
photo: currentPhoto,
created_at: new Date().toISOString()
};
// TODO: API 호출하여 항목 저장
console.log('새 항목 등록:', itemData);
// 성공 메시지
alert('항목이 등록되었습니다!');
// 모달 닫기 및 데이터 새로고침
closeUploadModal();
initializeDashboard();
} catch (error) {
console.error('항목 등록 실패:', error);
alert('항목 등록에 실패했습니다.');
} finally {
showLoading(false);
}
}
// 로딩 표시
function showLoading(show) {
const overlay = document.getElementById('loadingOverlay');
if (overlay) {
if (show) {
overlay.classList.remove('hidden');
} else {
overlay.classList.add('hidden');
}
}
}
// 이벤트 리스너 설정
document.addEventListener('DOMContentLoaded', () => {
// 파일 입력 이벤트 리스너
const fileInputs = ['desktopFileInput', 'cameraInput', 'galleryInput'];
fileInputs.forEach(id => {
const input = document.getElementById(id);
if (input) {
input.addEventListener('change', handlePhotoUpload);
}
});
// 업로드 폼 이벤트 리스너
const uploadForm = document.getElementById('uploadForm');
if (uploadForm) {
uploadForm.addEventListener('submit', handleUploadSubmit);
}
// 드래그 앤 드롭 (데스크톱)
const desktopUpload = document.querySelector('.desktop-upload');
if (desktopUpload) {
desktopUpload.addEventListener('dragover', (e) => {
e.preventDefault();
e.currentTarget.classList.add('border-blue-300');
});
desktopUpload.addEventListener('dragleave', (e) => {
e.preventDefault();
e.currentTarget.classList.remove('border-blue-300');
});
desktopUpload.addEventListener('drop', (e) => {
e.preventDefault();
e.currentTarget.classList.remove('border-blue-300');
const files = e.dataTransfer.files;
if (files.length > 0) {
const input = document.getElementById('desktopFileInput');
if (input) {
input.files = files;
handlePhotoUpload({ target: input });
}
}
});
}
// 모달 외부 클릭 시 닫기
const modal = document.getElementById('uploadModal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeUploadModal();
}
});
}
});
// 전역 함수 등록
window.previousMonth = previousMonth;
window.nextMonth = nextMonth;
window.goToToday = goToToday;
window.selectDate = selectDate;
window.toggleChecklistItem = toggleChecklistItem;
window.goBack = goBack;
window.openUploadModal = openUploadModal;
window.closeUploadModal = closeUploadModal;
window.selectFile = selectFile;
window.openCamera = openCamera;
window.openGallery = openGallery;
window.removePhoto = removePhoto;
window.goToClassify = goToClassify;
</script>
</body>
</html>

BIN
frontend/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

282
frontend/index.html Normal file
View File

@@ -0,0 +1,282 @@
<!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>
<!-- PWA 설정 -->
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#6366f1">
<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">
<!-- 파비콘 -->
<link rel="icon" href="favicon.ico" sizes="any">
<link rel="icon" href="static/icons/icon-192x192.png" type="image/png">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="static/icons/apple-touch-icon.png">
<link rel="apple-touch-icon" sizes="167x167" href="static/icons/apple-touch-icon-ipad.png">
<!-- 추가 아이콘 크기들 -->
<link rel="icon" sizes="72x72" href="static/icons/icon-72x72.png">
<link rel="icon" sizes="96x96" href="static/icons/icon-96x96.png">
<link rel="icon" sizes="128x128" href="static/icons/icon-128x128.png">
<link rel="icon" sizes="144x144" href="static/icons/icon-144x144.png">
<link rel="icon" sizes="152x152" href="static/icons/icon-152x152.png">
<link rel="icon" sizes="192x192" href="static/icons/icon-192x192.png">
<link rel="icon" sizes="384x384" href="static/icons/icon-384x384.png">
<link rel="icon" sizes="512x512" href="static/icons/icon-512x512.png">
<!-- 외부 라이브러리 -->
<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);
}
.input-field {
border: 1px solid var(--gray-300);
background: white;
transition: all 0.2s;
}
.input-field:focus {
border-color: var(--primary);
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.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);
}
.status-draft { color: #6b7280; }
.status-scheduled { color: var(--primary); }
.status-active { color: var(--warning); }
.status-completed { color: var(--success); }
.status-delayed { color: var(--danger); }
</style>
</head>
<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>
<form id="loginForm" class="space-y-4">
<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>
</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>
</div>
<button type="submit" class="btn-primary w-full py-2 px-4 rounded-lg font-medium">
로그인
</button>
</form>
<div class="mt-4 text-xs text-gray-500 text-center">
<p>테스트 계정: user1 / password123</p>
</div>
</div>
</div>
<!-- 메인 애플리케이션 -->
<div id="mainApp" class="hidden 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">
<i class="fas fa-tasks text-2xl text-blue-500 mr-3"></i>
<h1 class="text-xl font-semibold text-gray-800">Todo Project</h1>
</div>
<div class="flex items-center space-x-4">
<button onclick="goToClassify()" class="text-purple-600 hover:text-purple-800 font-medium">
<i class="fas fa-inbox mr-1"></i>분류 센터
<span class="ml-1 px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full">3</span>
</button>
<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-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 mb-8 border border-blue-100">
<div class="text-center">
<i class="fas fa-plus-circle text-3xl text-blue-500 mb-3"></i>
<h2 class="text-xl font-semibold text-gray-800 mb-2">새 항목을 등록하시겠어요?</h2>
<p class="text-gray-600 mb-4">대시보드에서 사진과 메모를 함께 등록할 수 있습니다.</p>
<button onclick="goToDashboard()" class="btn-primary px-6 py-3 rounded-lg font-medium">
<i class="fas fa-chart-line mr-2"></i>대시보드에서 등록하기
</button>
</div>
</div>
<!-- 등록된 항목들 -->
<div class="bg-white rounded-xl shadow-sm">
<div class="p-6 border-b">
<h2 class="text-lg font-semibold text-gray-800 mb-4">
<i class="fas fa-list text-blue-500 mr-2"></i>등록된 항목들
</h2>
<!-- 분류 안내 -->
<div class="bg-blue-50 rounded-lg p-4 mb-4">
<p class="text-sm text-blue-800 mb-2">
<i class="fas fa-info-circle mr-2"></i>등록된 항목을 클릭하여 3가지 방법으로 분류하세요:
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm">
<div class="flex items-center text-blue-700">
<i class="fas fa-calendar-day mr-2"></i>
<span><strong>Todo:</strong> 시작 날짜가 있는 일</span>
</div>
<div class="flex items-center text-blue-700">
<i class="fas fa-calendar-times mr-2 text-orange-500"></i>
<span><strong>캘린더:</strong> 마감 기한이 있는 일</span>
</div>
<div class="flex items-center text-blue-700">
<i class="fas fa-check-square mr-2"></i>
<span><strong>체크리스트:</strong> 기한 없는 일</span>
</div>
</div>
</div>
</div>
<div id="itemsList" class="divide-y divide-gray-100">
<!-- 등록된 항목들이 여기에 동적으로 추가됩니다 -->
</div>
<div id="emptyState" class="p-12 text-center text-gray-500">
<i class="fas fa-inbox text-4xl mb-4 opacity-50"></i>
<p>아직 등록된 항목이 없습니다.</p>
<p class="text-sm">위에서 새로운 항목을 등록해보세요!</p>
</div>
</div>
<!-- 분류 페이지 링크 -->
<div class="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Todo 페이지 -->
<div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow cursor-pointer" onclick="goToPage('todo')">
<div class="text-center">
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-calendar-day text-2xl text-blue-600"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">Todo</h3>
<p class="text-gray-600 text-sm mb-4">시작 날짜가 있는 일들</p>
<div class="bg-blue-50 rounded-lg p-3">
<span class="text-blue-800 font-medium" id="todoCount">0개</span>
</div>
</div>
</div>
<!-- 캘린더 페이지 -->
<div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow cursor-pointer" onclick="goToPage('calendar')">
<div class="text-center">
<div class="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-calendar-times text-2xl text-orange-500"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">캘린더</h3>
<p class="text-gray-600 text-sm mb-4">마감 기한이 있는 일들</p>
<div class="bg-orange-50 rounded-lg p-3">
<span class="text-orange-700 font-medium" id="calendarCount">0개</span>
</div>
</div>
</div>
<!-- 체크리스트 페이지 -->
<div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow cursor-pointer" onclick="goToPage('checklist')">
<div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="fas fa-check-square text-2xl text-green-600"></i>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2">체크리스트</h3>
<p class="text-gray-600 text-sm mb-4">기한 없는 일들</p>
<div class="bg-green-50 rounded-lg p-3">
<span class="text-green-800 font-medium" id="checklistCount">0개</span>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 로딩 오버레이 -->
<div id="loadingOverlay" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 text-center">
<i class="fas fa-spinner fa-spin text-2xl text-indigo-600 mb-3"></i>
<p class="text-gray-700">처리 중...</p>
</div>
</div>
<!-- JavaScript -->
<script src="static/js/image-utils.js"></script>
<script src="static/js/api.js"></script>
<script src="static/js/todos.js"></script>
<script src="static/js/auth.js"></script>
</body>
</html>

104
frontend/manifest.json Normal file
View File

@@ -0,0 +1,104 @@
{
"name": "Todo Project - 간결한 할일 관리",
"short_name": "Todo Project",
"description": "사진과 메모를 기반으로 한 간단한 일정관리 시스템",
"start_url": "/",
"display": "standalone",
"background_color": "#f9fafb",
"theme_color": "#6366f1",
"orientation": "portrait-primary",
"categories": ["productivity", "utilities"],
"lang": "ko",
"icons": [
{
"src": "static/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "static/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "static/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "static/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "static/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "static/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "빠른 할일 추가",
"short_name": "할일 추가",
"description": "새로운 할일을 빠르게 추가합니다",
"url": "/?action=add",
"icons": [
{
"src": "static/icons/shortcut-add.png",
"sizes": "96x96"
}
]
},
{
"name": "진행중인 할일",
"short_name": "진행중",
"description": "현재 진행중인 할일을 확인합니다",
"url": "/?filter=active",
"icons": [
{
"src": "static/icons/shortcut-active.png",
"sizes": "96x96"
}
]
}
],
"screenshots": [
{
"src": "static/screenshots/desktop-1.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide",
"label": "데스크톱 메인 화면"
},
{
"src": "static/screenshots/mobile-1.png",
"sizes": "375x812",
"type": "image/png",
"form_factor": "narrow",
"label": "모바일 메인 화면"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

165
frontend/static/js/api.js Normal file
View File

@@ -0,0 +1,165 @@
/**
* API 통신 유틸리티
*/
const API_BASE_URL = 'http://localhost:9000/api';
class ApiClient {
constructor() {
this.token = localStorage.getItem('authToken');
}
async request(endpoint, options = {}) {
const url = `${API_BASE_URL}${endpoint}`;
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
// 인증 토큰 추가
if (this.token) {
config.headers['Authorization'] = `Bearer ${this.token}`;
}
try {
const response = await fetch(url, config);
if (!response.ok) {
if (response.status === 401) {
// 토큰 만료 시 로그아웃
this.logout();
throw new Error('인증이 만료되었습니다. 다시 로그인해주세요.');
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
}
return await response.text();
} catch (error) {
console.error('API 요청 실패:', error);
throw error;
}
}
// GET 요청
async get(endpoint) {
return this.request(endpoint, { method: 'GET' });
}
// POST 요청
async post(endpoint, data) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
// PUT 요청
async put(endpoint, data) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
// DELETE 요청
async delete(endpoint) {
return this.request(endpoint, { method: 'DELETE' });
}
// 파일 업로드
async uploadFile(endpoint, formData) {
return this.request(endpoint, {
method: 'POST',
headers: {
// Content-Type을 설정하지 않음 (FormData가 자동으로 설정)
},
body: formData
});
}
// 토큰 설정
setToken(token) {
this.token = token;
localStorage.setItem('authToken', token);
}
// 로그아웃
logout() {
this.token = null;
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
window.location.reload();
}
}
// 전역 API 클라이언트 인스턴스
const api = new ApiClient();
// 인증 관련 API
const AuthAPI = {
async login(username, password) {
const response = await api.post('/auth/login', {
username,
password
});
if (response.access_token) {
api.setToken(response.access_token);
localStorage.setItem('currentUser', JSON.stringify(response.user));
}
return response;
},
async logout() {
try {
await api.post('/auth/logout');
} catch (error) {
console.error('로그아웃 API 호출 실패:', error);
} finally {
api.logout();
}
},
async getCurrentUser() {
return api.get('/auth/me');
}
};
// Todo 관련 API
const TodoAPI = {
async getTodos(filter = 'all') {
const params = filter !== 'all' ? `?status=${filter}` : '';
return api.get(`/todos${params}`);
},
async createTodo(todoData) {
return api.post('/todos', todoData);
},
async updateTodo(id, todoData) {
return api.put(`/todos/${id}`, todoData);
},
async deleteTodo(id) {
return api.delete(`/todos/${id}`);
},
async uploadImage(imageFile) {
const formData = new FormData();
formData.append('image', imageFile);
return api.uploadFile('/todos/upload-image', formData);
}
};
// 전역으로 사용 가능하도록 export
window.api = api;
window.AuthAPI = AuthAPI;
window.TodoAPI = TodoAPI;

139
frontend/static/js/auth.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* 인증 관리
*/
let currentUser = null;
// 페이지 로드 시 인증 상태 확인
document.addEventListener('DOMContentLoaded', () => {
checkAuthStatus();
setupLoginForm();
});
// 인증 상태 확인
function checkAuthStatus() {
const token = localStorage.getItem('authToken');
const userData = localStorage.getItem('currentUser');
if (token && userData) {
try {
currentUser = JSON.parse(userData);
showMainApp();
} catch (error) {
console.error('사용자 데이터 파싱 실패:', error);
logout();
}
} else {
showLoginScreen();
}
}
// 로그인 폼 설정
function setupLoginForm() {
const loginForm = document.getElementById('loginForm');
if (loginForm) {
loginForm.addEventListener('submit', handleLogin);
}
}
// 로그인 처리
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!username || !password) {
alert('사용자명과 비밀번호를 입력해주세요.');
return;
}
try {
showLoading(true);
// 임시 로그인 (백엔드 구현 전까지)
if (username === 'user1' && password === 'password123') {
const mockUser = {
id: 1,
username: 'user1',
email: 'user1@todo-project.local',
full_name: '사용자1'
};
currentUser = mockUser;
localStorage.setItem('authToken', 'mock-token-' + Date.now());
localStorage.setItem('currentUser', JSON.stringify(mockUser));
showMainApp();
} else {
throw new Error('잘못된 사용자명 또는 비밀번호입니다.');
}
// 실제 API 호출 (백엔드 구현 후 사용)
/*
const response = await AuthAPI.login(username, password);
currentUser = response.user;
showMainApp();
*/
} catch (error) {
console.error('로그인 실패:', error);
alert(error.message || '로그인에 실패했습니다.');
} finally {
showLoading(false);
}
}
// 로그아웃
function logout() {
currentUser = null;
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
showLoginScreen();
}
// 로그인 화면 표시
function showLoginScreen() {
document.getElementById('loginScreen').classList.remove('hidden');
document.getElementById('mainApp').classList.add('hidden');
// 폼 초기화
const loginForm = document.getElementById('loginForm');
if (loginForm) {
loginForm.reset();
}
}
// 메인 앱 표시
function showMainApp() {
document.getElementById('loginScreen').classList.add('hidden');
document.getElementById('mainApp').classList.remove('hidden');
// 사용자 정보 표시
const currentUserElement = document.getElementById('currentUser');
if (currentUserElement && currentUser) {
currentUserElement.textContent = currentUser.full_name || currentUser.username;
}
// Todo 목록 로드
if (typeof loadTodos === 'function') {
loadTodos();
}
}
// 로딩 상태 표시
function showLoading(show) {
const loadingOverlay = document.getElementById('loadingOverlay');
if (loadingOverlay) {
if (show) {
loadingOverlay.classList.remove('hidden');
} else {
loadingOverlay.classList.add('hidden');
}
}
}
// 전역으로 사용 가능하도록 export
window.currentUser = currentUser;
window.logout = logout;
window.showLoading = showLoading;

View File

@@ -0,0 +1,134 @@
/**
* 이미지 압축 및 최적화 유틸리티
*/
const ImageUtils = {
/**
* 이미지를 압축하고 리사이즈
* @param {File|Blob|String} source - 이미지 파일, Blob 또는 base64 문자열
* @param {Object} options - 압축 옵션
* @returns {Promise<String>} - 압축된 base64 이미지
*/
async compressImage(source, options = {}) {
const {
maxWidth = 1024, // 최대 너비
maxHeight = 1024, // 최대 높이
quality = 0.7, // JPEG 품질 (0-1)
format = 'jpeg' // 출력 형식
} = options;
return new Promise((resolve, reject) => {
let img = new Image();
// 이미지 로드 완료 시
img.onload = () => {
// Canvas 생성
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 리사이즈 계산
let { width, height } = this.calculateDimensions(
img.width,
img.height,
maxWidth,
maxHeight
);
// Canvas 크기 설정
canvas.width = width;
canvas.height = height;
// 이미지 그리기
ctx.drawImage(img, 0, 0, width, height);
// 압축된 이미지를 base64로 변환
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('이미지 압축 실패'));
return;
}
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
}, `image/${format}`, quality);
};
img.onerror = () => reject(new Error('이미지 로드 실패'));
// 소스 타입에 따라 처리
if (typeof source === 'string') {
// Base64 문자열인 경우
img.src = source;
} else if (source instanceof File || source instanceof Blob) {
// File 또는 Blob인 경우
const reader = new FileReader();
reader.onloadend = () => {
img.src = reader.result;
};
reader.onerror = reject;
reader.readAsDataURL(source);
} else {
reject(new Error('지원하지 않는 이미지 형식'));
}
});
},
/**
* 이미지 크기 계산 (비율 유지)
*/
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
// 원본 크기가 제한 내에 있으면 그대로 반환
if (originalWidth <= maxWidth && originalHeight <= maxHeight) {
return { width: originalWidth, height: originalHeight };
}
// 비율 계산
const widthRatio = maxWidth / originalWidth;
const heightRatio = maxHeight / originalHeight;
const ratio = Math.min(widthRatio, heightRatio);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
},
/**
* 파일 크기를 사람이 읽을 수 있는 형식으로 변환
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
},
/**
* Base64 문자열의 크기 계산
*/
getBase64Size(base64String) {
const base64Length = base64String.length - (base64String.indexOf(',') + 1);
const padding = (base64String.charAt(base64String.length - 2) === '=') ? 2 :
((base64String.charAt(base64String.length - 1) === '=') ? 1 : 0);
return (base64Length * 0.75) - padding;
},
/**
* 이미지 미리보기 생성 (썸네일)
*/
async createThumbnail(source, size = 150) {
return this.compressImage(source, {
maxWidth: size,
maxHeight: size,
quality: 0.8
});
}
};
// 전역으로 사용 가능하도록 export
window.ImageUtils = ImageUtils;

589
frontend/static/js/todos.js Normal file
View File

@@ -0,0 +1,589 @@
/**
* Todo 관리 기능
*/
let todos = [];
let currentPhoto = null;
let currentFilter = 'all';
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', () => {
setupTodoForm();
setupPhotoUpload();
setupFilters();
updateItemCounts();
loadRegisteredItems();
});
// Todo 폼 설정
function setupTodoForm() {
const todoForm = document.getElementById('todoForm');
if (todoForm) {
todoForm.addEventListener('submit', handleTodoSubmit);
}
}
// 사진 업로드 설정
function setupPhotoUpload() {
const cameraInput = document.getElementById('cameraInput');
const galleryInput = document.getElementById('galleryInput');
if (cameraInput) {
cameraInput.addEventListener('change', handlePhotoUpload);
}
if (galleryInput) {
galleryInput.addEventListener('change', handlePhotoUpload);
}
}
// 필터 설정
function setupFilters() {
// 필터 탭 클릭 이벤트는 HTML에서 onclick으로 처리
}
// Todo 제출 처리
async function handleTodoSubmit(event) {
event.preventDefault();
const content = document.getElementById('todoContent').value.trim();
if (!content) {
alert('할일 내용을 입력해주세요.');
return;
}
try {
showLoading(true);
const todoData = {
content: content,
photo: currentPhoto,
status: 'draft',
created_at: new Date().toISOString()
};
// 임시 저장 (백엔드 구현 전까지)
const newTodo = {
id: Date.now(),
...todoData,
user_id: currentUser?.id || 1
};
todos.unshift(newTodo);
// 실제 API 호출 (백엔드 구현 후 사용)
/*
const newTodo = await TodoAPI.createTodo(todoData);
todos.unshift(newTodo);
*/
// 폼 초기화 및 목록 업데이트
clearForm();
loadRegisteredItems();
updateItemCounts();
// 성공 메시지
showToast('항목이 등록되었습니다!', 'success');
} catch (error) {
console.error('할일 추가 실패:', error);
alert(error.message || '할일 추가에 실패했습니다.');
} finally {
showLoading(false);
}
}
// 사진 업로드 처리
async function handlePhotoUpload(event) {
const files = event.target.files;
if (!files || files.length === 0) return;
const file = files[0];
try {
showLoading(true);
// 이미지 압축
const compressedImage = await ImageUtils.compressImage(file, {
maxWidth: 800,
maxHeight: 600,
quality: 0.8
});
currentPhoto = compressedImage;
// 미리보기 표시
const previewContainer = document.getElementById('photoPreview');
const previewImage = document.getElementById('previewImage');
if (previewContainer && previewImage) {
previewImage.src = compressedImage;
previewContainer.classList.remove('hidden');
}
} catch (error) {
console.error('이미지 처리 실패:', error);
alert('이미지 처리에 실패했습니다.');
} finally {
showLoading(false);
}
}
// 카메라 열기
function openCamera() {
const cameraInput = document.getElementById('cameraInput');
if (cameraInput) {
cameraInput.click();
}
}
// 갤러리 열기
function openGallery() {
const galleryInput = document.getElementById('galleryInput');
if (galleryInput) {
galleryInput.click();
}
}
// 사진 제거
function removePhoto() {
currentPhoto = null;
const previewContainer = document.getElementById('photoPreview');
const previewImage = document.getElementById('previewImage');
if (previewContainer) {
previewContainer.classList.add('hidden');
}
if (previewImage) {
previewImage.src = '';
}
// 파일 입력 초기화
const cameraInput = document.getElementById('cameraInput');
const galleryInput = document.getElementById('galleryInput');
if (cameraInput) cameraInput.value = '';
if (galleryInput) galleryInput.value = '';
}
// 폼 초기화
function clearForm() {
const todoForm = document.getElementById('todoForm');
if (todoForm) {
todoForm.reset();
}
removePhoto();
}
// Todo 목록 로드
async function loadTodos() {
try {
// 임시 데이터 (백엔드 구현 전까지)
if (todos.length === 0) {
todos = [
{
id: 1,
content: '프로젝트 문서 검토',
status: 'active',
photo: null,
created_at: new Date(Date.now() - 86400000).toISOString(),
user_id: 1
},
{
id: 2,
content: '회의 준비',
status: 'completed',
photo: null,
created_at: new Date(Date.now() - 172800000).toISOString(),
user_id: 1
}
];
}
// 실제 API 호출 (백엔드 구현 후 사용)
/*
todos = await TodoAPI.getTodos(currentFilter);
*/
renderTodos();
} catch (error) {
console.error('할일 목록 로드 실패:', error);
showToast('할일 목록을 불러오는데 실패했습니다.', 'error');
}
}
// Todo 목록 렌더링
function renderTodos() {
const todoList = document.getElementById('todoList');
const emptyState = document.getElementById('emptyState');
if (!todoList || !emptyState) return;
// 필터링
const filteredTodos = todos.filter(todo => {
if (currentFilter === 'all') return true;
if (currentFilter === 'active') return ['draft', 'scheduled', 'active', 'delayed'].includes(todo.status);
if (currentFilter === 'completed') return todo.status === 'completed';
return todo.status === currentFilter;
});
// 빈 상태 처리
if (filteredTodos.length === 0) {
todoList.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
// Todo 항목 렌더링
todoList.innerHTML = filteredTodos.map(todo => `
<div class="todo-item p-4 hover:bg-gray-50 transition-colors">
<div class="flex items-start space-x-4">
<!-- 체크박스 -->
<button onclick="toggleTodo(${todo.id})" class="mt-1 flex-shrink-0">
<i class="fas ${todo.status === 'completed' ? 'fa-check-circle text-green-500' : 'fa-circle text-gray-300'} text-lg"></i>
</button>
<!-- 사진 (있는 경우) -->
${todo.photo ? `
<div class="flex-shrink-0">
<img src="${todo.photo}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<p class="text-gray-900 ${todo.status === 'completed' ? 'line-through text-gray-500' : ''}">${todo.content}</p>
<div class="flex items-center space-x-3 mt-2 text-sm text-gray-500">
<span class="status-${todo.status}">
<i class="fas ${getStatusIcon(todo.status)} mr-1"></i>${getStatusText(todo.status)}
</span>
<span>${formatDate(todo.created_at)}</span>
</div>
</div>
<!-- 액션 버튼 -->
<div class="flex-shrink-0 flex space-x-2">
${todo.status !== 'completed' ? `
<button onclick="editTodo(${todo.id})" class="text-gray-400 hover:text-blue-500">
<i class="fas fa-edit"></i>
</button>
` : ''}
<button onclick="deleteTodo(${todo.id})" class="text-gray-400 hover:text-red-500">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
`).join('');
}
// Todo 상태 토글
async function toggleTodo(id) {
try {
const todo = todos.find(t => t.id === id);
if (!todo) return;
const newStatus = todo.status === 'completed' ? 'active' : 'completed';
// 임시 업데이트
todo.status = newStatus;
// 실제 API 호출 (백엔드 구현 후 사용)
/*
await TodoAPI.updateTodo(id, { status: newStatus });
*/
renderTodos();
showToast(newStatus === 'completed' ? '할일을 완료했습니다!' : '할일을 다시 활성화했습니다!', 'success');
} catch (error) {
console.error('할일 상태 변경 실패:', error);
showToast('상태 변경에 실패했습니다.', 'error');
}
}
// Todo 삭제
async function deleteTodo(id) {
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) return;
try {
// 임시 삭제
todos = todos.filter(t => t.id !== id);
// 실제 API 호출 (백엔드 구현 후 사용)
/*
await TodoAPI.deleteTodo(id);
*/
renderTodos();
showToast('할일이 삭제되었습니다.', 'success');
} catch (error) {
console.error('할일 삭제 실패:', error);
showToast('삭제에 실패했습니다.', 'error');
}
}
// Todo 편집 (향후 구현)
function editTodo(id) {
// TODO: 편집 모달 또는 인라인 편집 구현
console.log('편집 기능 구현 예정:', id);
}
// 필터 변경
function filterTodos(filter) {
currentFilter = filter;
// 탭 활성화 상태 변경
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active', 'bg-white', 'text-blue-600');
tab.classList.add('text-gray-600');
});
event.target.classList.add('active', 'bg-white', 'text-blue-600');
event.target.classList.remove('text-gray-600');
renderTodos();
}
// 상태 아이콘 반환
function getStatusIcon(status) {
const icons = {
draft: 'fa-edit',
scheduled: 'fa-calendar',
active: 'fa-play',
completed: 'fa-check',
delayed: 'fa-clock'
};
return icons[status] || 'fa-circle';
}
// 상태 텍스트 반환
function getStatusText(status) {
const texts = {
draft: '검토 필요',
scheduled: '예정됨',
active: '진행중',
completed: '완료됨',
delayed: '지연됨'
};
return texts[status] || '알 수 없음';
}
// 날짜 포맷팅
function formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffTime = now - date;
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return '오늘';
if (diffDays === 1) return '어제';
if (diffDays < 7) return `${diffDays}일 전`;
return date.toLocaleDateString('ko-KR');
}
// 토스트 메시지 표시
function showToast(message, type = 'info') {
// 간단한 alert으로 대체 (향후 토스트 UI 구현)
console.log(`[${type.toUpperCase()}] ${message}`);
if (type === 'error') {
alert(message);
}
}
// 페이지 이동 함수
function goToPage(pageType) {
const pages = {
'todo': 'todo.html',
'calendar': 'calendar.html',
'checklist': 'checklist.html'
};
if (pages[pageType]) {
window.location.href = pages[pageType];
} else {
console.error('Unknown page type:', pageType);
}
}
// 대시보드로 이동
function goToDashboard() {
window.location.href = 'dashboard.html';
}
// 분류 센터로 이동
function goToClassify() {
window.location.href = 'classify.html';
}
// 항목 등록 후 인덱스 업데이트
function updateItemCounts() {
// TODO: API에서 각 분류별 항목 수를 가져와서 업데이트
// 임시로 하드코딩된 값 사용
const todoCount = document.getElementById('todoCount');
const calendarCount = document.getElementById('calendarCount');
const checklistCount = document.getElementById('checklistCount');
if (todoCount) todoCount.textContent = '2개';
if (calendarCount) calendarCount.textContent = '3개';
if (checklistCount) checklistCount.textContent = '5개';
}
// 등록된 항목들 로드
function loadRegisteredItems() {
// 임시 데이터 (실제로는 API에서 가져옴)
const sampleItems = [
{
id: 1,
content: '프로젝트 문서 정리',
photo_url: null,
category: null,
created_at: '2024-01-15'
},
{
id: 2,
content: '회의 자료 준비',
photo_url: null,
category: 'todo',
created_at: '2024-01-16'
},
{
id: 3,
content: '월말 보고서 작성',
photo_url: null,
category: 'calendar',
created_at: '2024-01-17'
}
];
renderRegisteredItems(sampleItems);
}
// 등록된 항목들 렌더링
function renderRegisteredItems(items) {
const itemsList = document.getElementById('itemsList');
const emptyState = document.getElementById('emptyState');
if (!itemsList || !emptyState) return;
if (!items || items.length === 0) {
itemsList.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
itemsList.innerHTML = items.map(item => `
<div class="p-6 hover:bg-gray-50 cursor-pointer transition-colors" onclick="showClassificationModal(${item.id})">
<div class="flex items-start space-x-4">
<!-- 사진 (있는 경우) -->
${item.photo_url ? `
<div class="flex-shrink-0">
<img src="${item.photo_url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<h4 class="text-gray-900 font-medium mb-2">${item.content}</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.category ? `
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getCategoryColor(item.category)}">
${getCategoryText(item.category)}
</span>
` : `
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
미분류
</span>
`}
</div>
</div>
<!-- 분류 아이콘 -->
<div class="flex-shrink-0">
<i class="fas fa-chevron-right text-gray-400"></i>
</div>
</div>
</div>
`).join('');
}
// 분류 모달 표시
function showClassificationModal(itemId) {
// TODO: 분류 선택 모달 구현
console.log('분류 모달 표시:', itemId);
// 임시로 confirm으로 분류 선택
const choice = prompt('분류를 선택하세요:\n1. Todo (시작 날짜)\n2. 캘린더 (마감 기한)\n3. 체크리스트 (기한 없음)\n\n번호를 입력하세요:');
if (choice) {
const categories = {
'1': 'todo',
'2': 'calendar',
'3': 'checklist'
};
const category = categories[choice];
if (category) {
classifyItem(itemId, category);
}
}
}
// 항목 분류
function classifyItem(itemId, category) {
// TODO: API 호출하여 항목 분류 업데이트
console.log('항목 분류:', itemId, category);
// 분류 후 해당 페이지로 이동
goToPage(category);
}
// 분류별 색상
function getCategoryColor(category) {
const colors = {
'todo': 'bg-blue-100 text-blue-800',
'calendar': 'bg-orange-100 text-orange-800',
'checklist': 'bg-green-100 text-green-800'
};
return colors[category] || 'bg-gray-100 text-gray-800';
}
// 분류별 텍스트
function getCategoryText(category) {
const texts = {
'todo': 'Todo',
'calendar': '캘린더',
'checklist': '체크리스트'
};
return texts[category] || '미분류';
}
// 전역으로 사용 가능하도록 export
window.loadTodos = loadTodos;
window.openCamera = openCamera;
window.openGallery = openGallery;
window.removePhoto = removePhoto;
window.clearForm = clearForm;
window.toggleTodo = toggleTodo;
window.deleteTodo = deleteTodo;
window.editTodo = editTodo;
window.filterTodos = filterTodos;
window.goToPage = goToPage;
window.goToDashboard = goToDashboard;
window.goToClassify = goToClassify;
window.showClassificationModal = showClassificationModal;
window.updateItemCounts = updateItemCounts;

310
frontend/todo.html Normal file
View File

@@ -0,0 +1,310 @@
<!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 항목 로드
function loadTodoItems() {
// 임시 데이터 (실제로는 API에서 가져옴)
const todoItems = [
{
id: 1,
content: '프로젝트 기획서 작성',
photo: null,
start_date: '2024-01-20',
status: 'scheduled',
created_at: '2024-01-15'
},
{
id: 2,
content: '팀 미팅 준비',
photo: null,
start_date: '2024-01-18',
status: 'active',
created_at: '2024-01-16'
}
];
renderTodoItems(todoItems);
}
// 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.photo ? `
<div class="flex-shrink-0">
<img src="${item.photo}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
</div>
` : ''}
<!-- 내용 -->
<div class="flex-1 min-w-0">
<h4 class="text-gray-900 font-medium mb-2">${item.content}</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.start_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="startTodo(${item.id})" class="text-blue-500 hover:text-blue-700" title="시작하기">
<i class="fas fa-play"></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="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) {
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR');
}
// Todo 시작
function startTodo(id) {
console.log('Todo 시작:', id);
// TODO: API 호출하여 상태를 'active'로 변경
}
// Todo 완료
function completeTodo(id) {
console.log('Todo 완료:', id);
// TODO: API 호출하여 상태를 'completed'로 변경
}
// Todo 편집
function editTodo(id) {
console.log('Todo 편집:', id);
// TODO: 편집 모달 또는 페이지로 이동
}
// 필터링
function filterTodos(filter) {
console.log('필터:', filter);
// TODO: 필터에 따라 목록 재로드
}
// 대시보드로 이동
function goToDashboard() {
window.location.href = 'dashboard.html';
}
// 전역 함수 등록
window.goToDashboard = goToDashboard;
</script>
</body>
</html>

113
generate_icons.py Normal file
View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
PWA 아이콘 생성 스크립트
원본 이미지를 다양한 크기의 아이콘으로 변환
"""
from PIL import Image, ImageDraw, ImageFilter
import os
def create_rounded_icon(image, size, corner_radius=None):
"""둥근 모서리 아이콘 생성"""
if corner_radius is None:
corner_radius = size // 8 # 기본값: 크기의 1/8
# 이미지 리사이즈
img = image.resize((size, size), Image.Resampling.LANCZOS)
# 마스크 생성 (둥근 사각형)
mask = Image.new('L', (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle([0, 0, size, size], corner_radius, fill=255)
# 마스크 적용
result = Image.new('RGBA', (size, size), (0, 0, 0, 0))
result.paste(img, (0, 0))
result.putalpha(mask)
return result
def main():
# 원본 이미지 로드
source_image = "DSCF0333.RAF_compressed.JPEG"
if not os.path.exists(source_image):
print(f"❌ 원본 이미지를 찾을 수 없습니다: {source_image}")
return
try:
with Image.open(source_image) as img:
# RGB로 변환 (RGBA가 아닌 경우)
if img.mode != 'RGB':
img = img.convert('RGB')
# 정사각형으로 크롭 (중앙 기준)
width, height = img.size
size = min(width, height)
left = (width - size) // 2
top = (height - size) // 2
img = img.crop((left, top, left + size, top + size))
# 아이콘 크기 목록
icon_sizes = [
(72, "icon-72x72.png"),
(96, "icon-96x96.png"),
(128, "icon-128x128.png"),
(144, "icon-144x144.png"),
(152, "icon-152x152.png"),
(192, "icon-192x192.png"),
(384, "icon-384x384.png"),
(512, "icon-512x512.png")
]
# Apple Touch Icon (둥근 모서리 없음)
apple_sizes = [
(180, "apple-touch-icon.png"),
(167, "apple-touch-icon-ipad.png")
]
# 아이콘 디렉토리 생성
icons_dir = "frontend/static/icons"
os.makedirs(icons_dir, exist_ok=True)
print("🎨 PWA 아이콘 생성 중...")
# PWA 아이콘 생성 (둥근 모서리)
for size, filename in icon_sizes:
icon = create_rounded_icon(img, size)
icon_path = os.path.join(icons_dir, filename)
icon.save(icon_path, "PNG", optimize=True)
print(f"{filename} ({size}x{size})")
# Apple Touch Icon 생성 (둥근 모서리 없음)
for size, filename in apple_sizes:
icon = img.resize((size, size), Image.Resampling.LANCZOS)
icon_path = os.path.join(icons_dir, filename)
icon.save(icon_path, "PNG", optimize=True)
print(f"{filename} ({size}x{size})")
# 파비콘 생성
favicon_sizes = [(16, 16), (32, 32), (48, 48)]
favicon_images = []
for size, _ in favicon_sizes:
favicon = img.resize((size, size), Image.Resampling.LANCZOS)
favicon_images.append(favicon)
# 멀티 사이즈 favicon.ico 생성
favicon_path = "frontend/favicon.ico"
favicon_images[0].save(
favicon_path,
format='ICO',
sizes=[(16, 16), (32, 32), (48, 48)],
append_images=favicon_images[1:]
)
print(f"✅ favicon.ico (16x16, 32x32, 48x48)")
print(f"\n🎉 총 {len(icon_sizes) + len(apple_sizes) + 1}개의 아이콘이 생성되었습니다!")
print(f"📁 아이콘 위치: {icons_dir}/")
except Exception as e:
print(f"❌ 아이콘 생성 실패: {e}")
if __name__ == "__main__":
main()