Initial commit: Todo Project with dashboard, classification center, and upload functionality
- 📱 PWA 지원: 홈화면 추가 가능한 Progressive Web App - 🎨 M-Project 색상 스키마: 하늘색, 주황색, 회색, 흰색 일관된 디자인 - 📊 대시보드: 데스크톱 캘린더 뷰 + 모바일 일일 뷰 반응형 디자인 - 📥 분류 센터: Gmail 스타일 받은편지함으로 스마트 분류 시스템 - 🤖 AI 분류 제안: 키워드 기반 자동 분류 제안 및 일괄 처리 - 📷 업로드 모달: 데스크톱(파일 선택) + 모바일(카메라/갤러리) 최적화 - 🏷️ 3가지 분류: Todo(시작일), 캘린더(마감일), 체크리스트(무기한) - 📋 체크리스트: 진행률 표시 및 완료 토글 기능 - 🔄 시놀로지 연동 준비: 메일플러스 연동을 위한 구조 설계 - 📱 반응형 UI: 모든 페이지 모바일 최적화 완료
21
.env.example
Normal 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
@@ -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
26
backend/Dockerfile
Normal 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
@@ -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
0
backend/src/api/__init__.py
Normal file
94
backend/src/api/dependencies.py
Normal 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
|
||||
7
backend/src/api/routes/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
API 라우터 모듈
|
||||
"""
|
||||
|
||||
from . import auth, todos, calendar
|
||||
|
||||
__all__ = ["auth", "todos", "calendar"]
|
||||
195
backend/src/api/routes/auth.py
Normal 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"}
|
||||
175
backend/src/api/routes/calendar.py
Normal 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)
|
||||
)
|
||||
313
backend/src/api/routes/todos.py
Normal 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)
|
||||
)
|
||||
0
backend/src/core/__init__.py
Normal file
44
backend/src/core/config.py
Normal 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()
|
||||
94
backend/src/core/database.py
Normal 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}")
|
||||
94
backend/src/core/security.py
Normal 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
|
||||
0
backend/src/integrations/__init__.py
Normal file
32
backend/src/integrations/calendar/__init__.py
Normal 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"
|
||||
370
backend/src/integrations/calendar/apple.py
Normal 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", "업무"]
|
||||
}
|
||||
332
backend/src/integrations/calendar/base.py
Normal 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 {}
|
||||
363
backend/src/integrations/calendar/router.py
Normal 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
|
||||
401
backend/src/integrations/calendar/synology.py
Normal 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
@@ -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
|
||||
)
|
||||
0
backend/src/models/__init__.py
Normal file
64
backend/src/models/todo.py
Normal 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]}...')>"
|
||||
43
backend/src/models/user.py
Normal 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}')>"
|
||||
0
backend/src/schemas/__init__.py
Normal file
57
backend/src/schemas/auth.py
Normal 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
@@ -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]
|
||||
12
backend/src/services/__init__.py
Normal 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"
|
||||
]
|
||||
74
backend/src/services/calendar_sync_service.py
Normal 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
|
||||
70
backend/src/services/file_service.py
Normal 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}")
|
||||
300
backend/src/services/todo_service.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 723 B |
282
frontend/index.html
Normal 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
@@ -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": "모바일 메인 화면"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
frontend/static/icons/apple-touch-icon-ipad.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/static/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
frontend/static/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/static/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/static/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/static/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
frontend/static/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
frontend/static/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
frontend/static/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/static/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
165
frontend/static/js/api.js
Normal 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
@@ -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;
|
||||
134
frontend/static/js/image-utils.js
Normal 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
@@ -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
@@ -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
@@ -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()
|
||||