feat: 다중 이미지 업로드 및 표시 기능 완전 구현

 주요 기능 추가:
- 다중 이미지 업로드 지원 (최대 5개)
- 체크리스트 완료 시 자동 사라짐 기능
- 캘린더 상세 모달에서 다중 이미지 표시
- Todo 변환 시 기존 이미지 보존 및 새 이미지 추가

🔧 백엔드 수정:
- Todo 모델: image_url → image_urls (JSON 배열)
- API 엔드포인트: 다중 이미지 직렬화/역직렬화
- 새 엔드포인트: POST /todos/{id}/add-image (이미지 추가)
- 데이터베이스 마이그레이션 스크립트 추가

🎨 프론트엔드 개선:
- 대시보드: 실제 API 데이터 연동, 다중 이미지 표시
- 업로드 모달: 다중 파일 선택, 실시간 미리보기, 5개 제한
- 체크리스트: 완료 시 1.5초 후 자동 제거, 토스트 메시지
- 캘린더 모달: 2x2 그리드 이미지 표시, 클릭 확대
- Todo 변환: 기존 이미지 + 새 이미지 합치기

🐛 버그 수정:
- currentPhoto 변수 오류 해결
- 이미지 표시 문제 (단일 → 다중 지원)
- 완료 처리 로컬/백엔드 동기화
- 새로고침 시 완료 항목 재출현 문제
This commit is contained in:
Hyungi Ahn
2025-09-23 08:47:45 +09:00
parent f80995c1ec
commit 4c7d2d8290
10 changed files with 539 additions and 121 deletions

View File

@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir -e .
# 애플리케이션 코드 복사
COPY src/ ./src/
COPY migrations/ ./migrations/
RUN mkdir -p ./migrations
# 환경 변수 설정
ENV PYTHONPATH=/app

View File

@@ -0,0 +1,19 @@
-- 다중 이미지 지원을 위한 마이그레이션
-- image_url 컬럼을 image_urls로 변경하고 기존 데이터 마이그레이션
-- 1. 새 컬럼 추가
ALTER TABLE todos ADD COLUMN image_urls TEXT;
-- 2. 기존 image_url 데이터를 image_urls로 마이그레이션 (JSON 배열 형태)
UPDATE todos
SET image_urls = CASE
WHEN image_url IS NOT NULL AND image_url != ''
THEN '["' || image_url || '"]'
ELSE NULL
END;
-- 3. 기존 image_url 컬럼 삭제
ALTER TABLE todos DROP COLUMN image_url;
-- 4. 인덱스 추가 (선택사항 - 검색 성능 향상)
-- CREATE INDEX idx_todos_has_images ON todos ((image_urls IS NOT NULL));

View File

@@ -44,6 +44,12 @@ async def create_todo(
except ValueError:
logger.warning(f"Invalid date format: {todo_data.due_date}")
# 이미지 URLs JSON 변환
import json
image_urls_json = None
if todo_data.image_urls:
image_urls_json = json.dumps(todo_data.image_urls)
# 새 Todo 생성
new_todo = Todo(
user_id=current_user.id,
@@ -51,7 +57,7 @@ async def create_todo(
description=todo_data.description,
category=todo_data.category,
due_date=parsed_due_date,
image_url=todo_data.image_url,
image_urls=image_urls_json,
tags=todo_data.tags
)
@@ -60,6 +66,14 @@ async def create_todo(
await db.refresh(new_todo)
# 응답용 데이터 변환
import json
image_urls_list = None
if new_todo.image_urls:
try:
image_urls_list = json.loads(new_todo.image_urls)
except json.JSONDecodeError:
image_urls_list = None
response_data = {
"id": new_todo.id,
"user_id": new_todo.user_id,
@@ -71,7 +85,7 @@ async def create_todo(
"updated_at": new_todo.updated_at,
"due_date": new_todo.due_date.isoformat() if new_todo.due_date else None,
"completed_at": new_todo.completed_at,
"image_url": new_todo.image_url,
"image_urls": image_urls_list,
"tags": new_todo.tags
}
@@ -113,22 +127,30 @@ async def get_todos(
todos = result.scalars().all()
# 응답용 데이터 변환
import json
response_data = []
for todo in todos:
response_data.append({
"id": todo.id,
"user_id": todo.user_id,
"title": todo.title,
"description": todo.description,
"category": todo.category,
"status": todo.status,
"created_at": todo.created_at,
"updated_at": todo.updated_at,
"due_date": todo.due_date.isoformat() if todo.due_date else None,
"completed_at": todo.completed_at,
"image_url": todo.image_url,
"tags": todo.tags
})
image_urls_list = None
if todo.image_urls:
try:
image_urls_list = json.loads(todo.image_urls)
except json.JSONDecodeError:
image_urls_list = None
response_data.append({
"id": todo.id,
"user_id": todo.user_id,
"title": todo.title,
"description": todo.description,
"category": todo.category,
"status": todo.status,
"created_at": todo.created_at,
"updated_at": todo.updated_at,
"due_date": todo.due_date.isoformat() if todo.due_date else None,
"completed_at": todo.completed_at,
"image_urls": image_urls_list,
"tags": todo.tags
})
return response_data
@@ -162,6 +184,14 @@ async def get_todo(
)
# 응답용 데이터 변환
import json
image_urls_list = None
if todo.image_urls:
try:
image_urls_list = json.loads(todo.image_urls)
except json.JSONDecodeError:
image_urls_list = None
response_data = {
"id": todo.id,
"user_id": todo.user_id,
@@ -173,7 +203,7 @@ async def get_todo(
"updated_at": todo.updated_at,
"due_date": todo.due_date.isoformat() if todo.due_date else None,
"completed_at": todo.completed_at,
"image_url": todo.image_url,
"image_urls": image_urls_list,
"tags": todo.tags,
}
@@ -213,6 +243,7 @@ async def update_todo(
)
# 업데이트할 필드만 수정
import json
update_data = todo_data.dict(exclude_unset=True)
for field, value in update_data.items():
if field == 'due_date' and value:
@@ -222,6 +253,9 @@ async def update_todo(
setattr(todo, field, parsed_date)
except ValueError:
logger.warning(f"Invalid date format: {value}")
elif field == 'image_urls' and value is not None:
# 이미지 URLs를 JSON으로 변환
setattr(todo, field, json.dumps(value))
else:
setattr(todo, field, value)
@@ -235,6 +269,13 @@ async def update_todo(
await db.refresh(todo)
# 응답용 데이터 변환
image_urls_list = None
if todo.image_urls:
try:
image_urls_list = json.loads(todo.image_urls)
except json.JSONDecodeError:
image_urls_list = None
response_data = {
"id": todo.id,
"user_id": todo.user_id,
@@ -246,7 +287,7 @@ async def update_todo(
"updated_at": todo.updated_at,
"due_date": todo.due_date.isoformat() if todo.due_date else None,
"completed_at": todo.completed_at,
"image_url": todo.image_url,
"image_urls": image_urls_list,
"tags": todo.tags,
}
@@ -300,3 +341,95 @@ async def delete_todo(
detail="Todo 삭제에 실패했습니다."
)
@router.post("/{todo_id}/add-image")
async def add_image_to_todo(
todo_id: UUID,
image: UploadFile = File(...),
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""기존 Todo에 이미지 추가 (최대 5개까지)"""
try:
# Todo 조회
result = await db.execute(
select(Todo).where(
and_(Todo.id == todo_id, Todo.user_id == current_user.id)
)
)
todo = result.scalar_one_or_none()
if not todo:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Todo를 찾을 수 없습니다."
)
# 현재 이미지 개수 확인
import json
current_images = []
if todo.image_urls:
try:
current_images = json.loads(todo.image_urls)
except json.JSONDecodeError:
current_images = []
# 최대 5개 제한 확인
if len(current_images) >= 5:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="최대 5개의 이미지만 저장할 수 있습니다."
)
# 파일 형식 확인
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()}"
# 파일 저장
from ...services.file_service import save_base64_image
file_url = save_base64_image(base64_string)
if not file_url:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="이미지 저장에 실패했습니다."
)
# 기존 이미지 목록에 새 이미지 추가
current_images.append(file_url)
todo.image_urls = json.dumps(current_images)
await db.commit()
await db.refresh(todo)
return {
"message": "이미지가 추가되었습니다.",
"url": file_url,
"total_images": len(current_images),
"max_images": 5
}
except HTTPException:
raise
except Exception as e:
await db.rollback()
logger.error(f"이미지 추가 실패: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="이미지 추가에 실패했습니다."
)

View File

@@ -47,7 +47,7 @@ class Todo(Base):
completed_at = Column(DateTime(timezone=True), nullable=True)
# 추가 정보
image_url = Column(Text, nullable=True) # 첨부 이미지 URL (Base64 데이터 지원)
image_urls = Column(Text, nullable=True) # 첨부 이미지 URLs (JSON 배열 형태, 최대 5개)
tags = Column(String(500), nullable=True) # 태그 (쉼표로 구분)
# 관계

View File

@@ -31,7 +31,7 @@ class TodoBase(BaseModel):
class TodoCreate(TodoBase):
"""Todo 생성"""
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
image_url: Optional[str] = Field(None, max_length=10000000) # Base64 이미지를 위해 크게 설정
image_urls: Optional[List[str]] = Field(None, max_items=5, description="최대 5개의 이미지 URL")
tags: Optional[str] = Field(None, max_length=500)
@@ -42,7 +42,7 @@ class TodoUpdate(BaseModel):
category: Optional[TodoCategoryEnum] = None
status: Optional[TodoStatusEnum] = None
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
image_url: Optional[str] = Field(None, max_length=10000000) # Base64 이미지를 위해 크게 설정
image_urls: Optional[List[str]] = Field(None, max_items=5, description="최대 5개의 이미지 URL")
tags: Optional[str] = Field(None, max_length=500)
@@ -57,7 +57,7 @@ class TodoResponse(BaseModel):
updated_at: datetime
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
completed_at: Optional[datetime] = None
image_url: Optional[str] = None
image_urls: Optional[List[str]] = None
tags: Optional[str] = None
class Config: