From 4c7d2d8290edf697e331f403f4326539d696694b Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 23 Sep 2025 08:47:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8B=A4=EC=A4=91=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=20=EC=99=84=EC=A0=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 주요 기능 추가: - 다중 이미지 업로드 지원 (최대 5개) - 체크리스트 완료 시 자동 사라짐 기능 - 캘린더 상세 모달에서 다중 이미지 표시 - Todo 변환 시 기존 이미지 보존 및 새 이미지 추가 🔧 백엔드 수정: - Todo 모델: image_url → image_urls (JSON 배열) - API 엔드포인트: 다중 이미지 직렬화/역직렬화 - 새 엔드포인트: POST /todos/{id}/add-image (이미지 추가) - 데이터베이스 마이그레이션 스크립트 추가 🎨 프론트엔드 개선: - 대시보드: 실제 API 데이터 연동, 다중 이미지 표시 - 업로드 모달: 다중 파일 선택, 실시간 미리보기, 5개 제한 - 체크리스트: 완료 시 1.5초 후 자동 제거, 토스트 메시지 - 캘린더 모달: 2x2 그리드 이미지 표시, 클릭 확대 - Todo 변환: 기존 이미지 + 새 이미지 합치기 🐛 버그 수정: - currentPhoto 변수 오류 해결 - 이미지 표시 문제 (단일 → 다중 지원) - 완료 처리 로컬/백엔드 동기화 - 새로고침 시 완료 항목 재출현 문제 --- backend/Dockerfile | 2 +- .../migrations/001_add_multiple_images.sql | 19 ++ backend/src/api/routes/todos.py | 169 ++++++++-- backend/src/models/todo.py | 2 +- backend/src/schemas/todo.py | 6 +- frontend/calendar.html | 13 +- frontend/checklist.html | 128 +++++++- frontend/classify.html | 13 +- frontend/dashboard.html | 295 +++++++++++++----- frontend/todo.html | 13 +- 10 files changed, 539 insertions(+), 121 deletions(-) create mode 100644 backend/migrations/001_add_multiple_images.sql diff --git a/backend/Dockerfile b/backend/Dockerfile index 59e19c7..9accb5d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,7 +13,7 @@ RUN pip install --no-cache-dir -e . # 애플리케이션 코드 복사 COPY src/ ./src/ -COPY migrations/ ./migrations/ +RUN mkdir -p ./migrations # 환경 변수 설정 ENV PYTHONPATH=/app diff --git a/backend/migrations/001_add_multiple_images.sql b/backend/migrations/001_add_multiple_images.sql new file mode 100644 index 0000000..4b271a3 --- /dev/null +++ b/backend/migrations/001_add_multiple_images.sql @@ -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)); diff --git a/backend/src/api/routes/todos.py b/backend/src/api/routes/todos.py index 4899d05..f750ea0 100644 --- a/backend/src/api/routes/todos.py +++ b/backend/src/api/routes/todos.py @@ -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="이미지 추가에 실패했습니다." + ) + diff --git a/backend/src/models/todo.py b/backend/src/models/todo.py index b88633d..3fc6c98 100644 --- a/backend/src/models/todo.py +++ b/backend/src/models/todo.py @@ -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) # 태그 (쉼표로 구분) # 관계 diff --git a/backend/src/schemas/todo.py b/backend/src/schemas/todo.py index 1220998..0ddf249 100644 --- a/backend/src/schemas/todo.py +++ b/backend/src/schemas/todo.py @@ -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: diff --git a/frontend/calendar.html b/frontend/calendar.html index 57223c3..07c0313 100644 --- a/frontend/calendar.html +++ b/frontend/calendar.html @@ -222,9 +222,18 @@ - ${item.photo ? ` + ${item.image_urls && item.image_urls.length > 0 ? `
- 첨부 사진 +
+ ${item.image_urls.slice(0, 3).map(url => ` + 첨부 사진 + `).join('')} + ${item.image_urls.length > 3 ? ` +
+ +${item.image_urls.length - 3} +
+ ` : ''} +
` : ''} diff --git a/frontend/checklist.html b/frontend/checklist.html index 6248c51..7e05218 100644 --- a/frontend/checklist.html +++ b/frontend/checklist.html @@ -229,6 +229,19 @@ try { // API에서 체크리스트 카테고리 항목들만 가져오기 const items = await TodoAPI.getTodos(null, 'checklist'); + console.log('🔍 체크리스트 API 응답 데이터:', items); + + // 각 항목의 이미지 데이터 확인 + items.forEach((item, index) => { + console.log(`📋 체크리스트 항목 ${index + 1}:`, { + id: item.id, + title: item.title, + image_urls: item.image_urls, + image_urls_type: typeof item.image_urls, + image_urls_length: item.image_urls ? item.image_urls.length : 'null' + }); + }); + checklistItems = items; } catch (error) { console.error('체크리스트 항목 로드 실패:', error); @@ -263,9 +276,18 @@ - ${item.photo ? ` + ${item.image_urls && item.image_urls.length > 0 ? `
- 첨부 사진 +
+ ${item.image_urls.slice(0, 3).map(url => ` + 첨부 사진 + `).join('')} + ${item.image_urls.length > 3 ? ` +
+ +${item.image_urls.length - 3} +
+ ` : ''} +
` : ''} @@ -318,17 +340,103 @@ 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); + if (!item.completed) { + // 완료 처리 - 애니메이션 후 제거 + item.completed = true; + item.completed_at = new Date().toISOString().split('T')[0]; + + // 즉시 완료 상태로 렌더링 + renderChecklistItems(checklistItems); + updateProgress(); + + // 1.5초 후 항목 제거 + setTimeout(() => { + const itemIndex = checklistItems.findIndex(i => i.id === id); + if (itemIndex !== -1) { + checklistItems.splice(itemIndex, 1); + renderChecklistItems(checklistItems); + updateProgress(); + + // 완료 메시지 표시 + showCompletionToast('항목이 완료되었습니다! 🎉'); + } + }, 1500); + + // API 호출하여 상태 업데이트 + updateTodoStatus(id, 'completed'); + console.log('체크리스트 완료 처리:', id); + } else { + // 완료 취소는 허용하지 않음 (이미 완료된 항목은 제거되므로) + console.log('완료된 항목은 취소할 수 없습니다.'); + } } } + // Todo 상태 업데이트 (API 호출) + async function updateTodoStatus(id, status) { + try { + const token = localStorage.getItem('authToken'); + if (!token) { + console.error('인증 토큰이 없습니다!'); + return; + } + + const response = await fetch(`http://localhost:9000/api/todos/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ status: status }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + console.log(`Todo ${id} 상태가 ${status}로 업데이트되었습니다.`); + } catch (error) { + console.error('Todo 상태 업데이트 실패:', error); + } + } + + // 완료 토스트 메시지 표시 + function showCompletionToast(message) { + // 기존 토스트가 있으면 제거 + const existingToast = document.getElementById('completionToast'); + if (existingToast) { + existingToast.remove(); + } + + // 새 토스트 생성 + const toast = document.createElement('div'); + toast.id = 'completionToast'; + toast.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-x-full'; + toast.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(toast); + + // 애니메이션으로 나타나기 + setTimeout(() => { + toast.classList.remove('translate-x-full'); + }, 100); + + // 3초 후 사라지기 + setTimeout(() => { + toast.classList.add('translate-x-full'); + setTimeout(() => { + if (toast.parentNode) { + toast.remove(); + } + }, 300); + }, 3000); + } + // 진행률 업데이트 function updateProgress() { const total = checklistItems.length; diff --git a/frontend/classify.html b/frontend/classify.html index 9bbdf76..61994eb 100644 --- a/frontend/classify.html +++ b/frontend/classify.html @@ -351,9 +351,18 @@ - ${item.photo ? ` + ${item.image_urls && item.image_urls.length > 0 ? `
- 첨부 사진 +
+ ${item.image_urls.slice(0, 2).map(url => ` + 첨부 사진 + `).join('')} + ${item.image_urls.length > 2 ? ` +
+ +${item.image_urls.length - 2} +
+ ` : ''} +
` : ''} diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 4ec37b1..ce4c486 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -540,7 +540,7 @@ - + @@ -556,8 +556,8 @@

갤러리

- - + + @@ -750,21 +750,23 @@ console.log(`🔍 원본 API 데이터 ${index + 1}:`, { id: todo.id, title: todo.title, - image_url: todo.image_url ? `있음 (${todo.image_url.length}자)` : '없음', + image_urls: todo.image_urls, + image_urls_type: typeof todo.image_urls, + image_urls_length: todo.image_urls ? todo.image_urls.length : 'null', category: todo.category }); }); - // 체크리스트 타입만 필터링 + // 체크리스트 타입만 필터링 (완료되지 않은 항목만) checklistData = todos - .filter(todo => todo.category === 'checklist') + .filter(todo => todo.category === 'checklist' && todo.status !== 'completed') .map(todo => ({ id: todo.id, title: todo.title, description: todo.description, created_at: todo.created_at, completed: todo.status === 'completed', - image_url: todo.image_url // 이미지 URL 포함 + image_urls: todo.image_urls || [] // 다중 이미지 URL 배열 })); console.log('체크리스트 데이터 로드 완료:', checklistData); @@ -935,8 +937,9 @@ console.log(`📝 체크리스트 항목 ${index + 1}:`, { id: item.id, title: item.title, - image_url: item.image_url ? '이미지 있음' : '이미지 없음', - image_length: item.image_url ? item.image_url.length : 0 + image_urls: item.image_urls, + image_urls_type: typeof item.image_urls, + image_urls_length: item.image_urls ? item.image_urls.length : 'null' }); return ` @@ -946,13 +949,22 @@
- ${item.image_url ? ` + ${item.image_urls && item.image_urls.length > 0 ? `
- 첨부 이미지 +
+ ${item.image_urls.slice(0, 2).map(url => ` + 첨부 이미지 + `).join('')} + ${item.image_urls.length > 2 ? ` +
+ +${item.image_urls.length - 2} +
+ ` : ''} +
` : ''} @@ -962,7 +974,7 @@ ${item.description ? `

${item.description}

` : ''}
등록: ${formatDate(item.created_at)} - ${item.image_url ? `이미지` : ''} + ${item.image_urls && item.image_urls.length > 0 ? `이미지 ${item.image_urls.length}개` : ''}
@@ -1007,21 +1019,74 @@ // 항목 완료 처리 async function completeItem(itemId) { try { + // 즉시 UI에서 완료 상태로 표시 + const item = checklistData.find(item => item.id === itemId); + if (item) { + item.status = 'completed'; + renderChecklist(); + + // 완료 토스트 메시지 표시 + showCompletionToast('항목이 완료되었습니다! 🎉'); + + // 1.5초 후 항목 제거 + setTimeout(async () => { + // 배열에서 제거 + const itemIndex = checklistData.findIndex(i => i.id === itemId); + if (itemIndex !== -1) { + checklistData.splice(itemIndex, 1); + renderChecklist(); + } + }, 1500); + } + + // API 호출하여 백엔드 업데이트 await TodoAPI.updateTodo(itemId, { status: 'completed' }); - alert('항목이 완료 처리되었습니다!'); - - // 대시보드 데이터 새로고침 - await initializeDashboard(); - } catch (error) { console.error('완료 처리 실패:', error); alert('완료 처리에 실패했습니다.'); } } + // 완료 토스트 메시지 표시 + function showCompletionToast(message) { + // 기존 토스트가 있으면 제거 + const existingToast = document.getElementById('completionToast'); + if (existingToast) { + existingToast.remove(); + } + + // 새 토스트 생성 + const toast = document.createElement('div'); + toast.id = 'completionToast'; + toast.className = 'fixed top-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 transform transition-all duration-300 translate-x-full'; + toast.innerHTML = ` +
+ + ${message} +
+ `; + + document.body.appendChild(toast); + + // 애니메이션으로 나타나기 + setTimeout(() => { + toast.classList.remove('translate-x-full'); + }, 100); + + // 3초 후 사라지기 + setTimeout(() => { + toast.classList.add('translate-x-full'); + setTimeout(() => { + if (toast.parentNode) { + toast.remove(); + } + }, 300); + }, 3000); + } + // Todo로 변경 function convertToTodo(itemId) { const item = checklistData.find(item => item.id === itemId); @@ -1174,9 +1239,15 @@ } try { - let imageUrl = null; + // 기존 항목의 이미지 정보 가져오기 + const existingItem = await TodoAPI.getTodoById(itemId); + let existingImages = existingItem.image_urls || []; - // 이미지가 선택된 경우 처리 + console.log('🔄 변환 시 기존 이미지:', existingImages); + + let newImageUrl = null; + + // 새 이미지가 선택된 경우 처리 if (fileInput.files && fileInput.files[0]) { const file = fileInput.files[0]; @@ -1187,7 +1258,8 @@ } // Base64로 인코딩하여 저장 - imageUrl = await convertFileToBase64(file); + newImageUrl = await convertFileToBase64(file); + console.log('🖼️ 새 이미지 추가:', newImageUrl ? '성공' : '실패'); } const updateData = { @@ -1205,9 +1277,19 @@ console.log('변환 모달 날짜 설정 - 선택된 날짜 (KST):', date, '저장:', kstDateTime); } - // 이미지가 있으면 추가 - if (imageUrl) { - updateData.image_url = imageUrl; + // 이미지 배열 처리: 기존 이미지 + 새 이미지 (최대 5개) + let finalImages = [...existingImages]; + if (newImageUrl) { + finalImages.push(newImageUrl); + // 최대 5개 제한 + if (finalImages.length > 5) { + finalImages = finalImages.slice(-5); // 최신 5개만 유지 + } + } + + if (finalImages.length > 0) { + updateData.image_urls = finalImages; + console.log('📸 최종 이미지 배열:', finalImages.length, '개'); } await TodoAPI.updateTodo(itemId, updateData); @@ -1313,10 +1395,7 @@ const info = categoryInfo[item.category] || categoryInfo['todo']; // 이미지 파일 확인 - const isImage = item.image_url && ( - item.image_url.includes('data:image/') || - /\.(jpg|jpeg|png|gif|webp)$/i.test(item.image_url) - ); + const hasImages = item.image_urls && item.image_urls.length > 0; // 첨부 파일 확인 및 타입 분류 const hasFile = item.file_url && item.file_name; @@ -1431,11 +1510,22 @@ - ${isImage ? ` + ${hasImages ? `
- +
- 첨부 이미지 +
+ ${item.image_urls.map((url, index) => ` +
+ 첨부 이미지 ${index + 1} +
+ ${index + 1}/${item.image_urls.length} +
+
+ `).join('')} +
` : ''} @@ -1807,7 +1897,7 @@ // 업로드 모달 관련 변수 - let currentPhoto = null; + let currentPhotos = []; // 업로드 모달 열기 function openUploadModal() { @@ -1825,7 +1915,7 @@ // 업로드 폼 초기화 function clearUploadForm() { document.getElementById('uploadForm').reset(); - removePhoto(); + removeAllPhotos(); } // 파일 선택 (데스크톱) @@ -1843,19 +1933,10 @@ 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 = ''; - } + // 모든 사진 제거 + function removeAllPhotos() { + currentPhotos = []; + updatePhotoPreview(); // 파일 입력 초기화 const inputs = ['desktopFileInput', 'cameraInput', 'galleryInput']; @@ -1865,44 +1946,56 @@ }); } - // 사진 업로드 처리 + // 개별 사진 제거 + function removePhoto(index) { + if (index >= 0 && index < currentPhotos.length) { + currentPhotos.splice(index, 1); + updatePhotoPreview(); + } + } + + // 사진 업로드 처리 (다중 이미지 지원) async function handlePhotoUpload(event) { const files = event.target.files; if (!files || files.length === 0) return; - const file = files[0]; + // 최대 5개 제한 확인 + const remainingSlots = 5 - currentPhotos.length; + if (remainingSlots <= 0) { + alert('최대 5개의 이미지만 업로드할 수 있습니다.'); + return; + } + + const filesToProcess = Array.from(files).slice(0, remainingSlots); try { showLoading(true); - // 이미지 압축 (ImageUtils가 있는 경우) - let processedImage; - if (window.ImageUtils) { - processedImage = await ImageUtils.compressImage(file, { - maxWidth: 800, - maxHeight: 600, - quality: 0.8 + for (const file of filesToProcess) { + // 이미지 압축 (ImageUtils가 있는 경우) + let processedImage; + if (window.ImageUtils) { + processedImage = await ImageUtils.compressImage(file, { + maxWidth: 800, + maxHeight: 600, + quality: 0.8 + }); + } else { + // 기본 처리 + processedImage = await fileToBase64(file); + } + + currentPhotos.push({ + data: processedImage, + name: file.name, + size: file.size }); - } else { - // 기본 처리 - processedImage = await fileToBase64(file); } - currentPhoto = processedImage; + updatePhotoPreview(); - // 미리보기 표시 - 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)`; + if (filesToProcess.length < files.length) { + alert(`최대 5개까지만 업로드됩니다. ${filesToProcess.length}개가 추가되었습니다.`); } } catch (error) { @@ -1913,6 +2006,44 @@ } } + // 사진 미리보기 업데이트 + function updatePhotoPreview() { + const previewContainer = document.getElementById('photoPreview'); + + if (currentPhotos.length === 0) { + if (previewContainer) { + previewContainer.classList.add('hidden'); + } + return; + } + + if (previewContainer) { + previewContainer.classList.remove('hidden'); + previewContainer.innerHTML = ` +
+
+ 업로드된 이미지 (${currentPhotos.length}/5) + +
+
+ ${currentPhotos.map((photo, index) => ` +
+ ${photo.name} + +
${photo.name}
+
+ `).join('')} +
+
+ `; + } + } + // 파일을 Base64로 변환 function fileToBase64(file) { return new Promise((resolve, reject) => { @@ -1927,7 +2058,7 @@ async function handleUploadSubmit(event) { event.preventDefault(); - console.log('🚀 업로드 시작 - currentPhoto:', currentPhoto ? `있음 (${currentPhoto.length}자)` : '없음'); + console.log('🚀 업로드 시작 - currentPhotos:', currentPhotos.length > 0 ? `${currentPhotos.length}개 이미지` : '이미지 없음'); const content = document.getElementById('uploadContent').value.trim(); if (!content) { @@ -1940,7 +2071,7 @@ const itemData = { content: content, - photo: currentPhoto, + image_urls: currentPhotos.map(photo => photo.data), created_at: new Date().toISOString() }; @@ -1951,13 +2082,13 @@ category: 'checklist' // 체크리스트로 바로 저장 }; - // 이미지가 있을 때만 image_url 추가 - if (currentPhoto) { - todoData.image_url = currentPhoto; + // 이미지가 있을 때만 image_urls 추가 + if (currentPhotos.length > 0) { + todoData.image_urls = currentPhotos.map(photo => photo.data); console.log('📸 이미지 데이터 포함:', { hasImage: true, - imageLength: currentPhoto.length, - imagePreview: currentPhoto.substring(0, 50) + '...' + imageCount: currentPhotos.length, + totalSize: currentPhotos.reduce((sum, photo) => sum + photo.size, 0) }); } else { console.log('📸 이미지 데이터 없음'); diff --git a/frontend/todo.html b/frontend/todo.html index 46558bf..be222ab 100644 --- a/frontend/todo.html +++ b/frontend/todo.html @@ -197,9 +197,18 @@ - ${item.photo ? ` + ${item.image_urls && item.image_urls.length > 0 ? `
- 첨부 사진 +
+ ${item.image_urls.slice(0, 3).map(url => ` + 첨부 사진 + `).join('')} + ${item.image_urls.length > 3 ? ` +
+ +${item.image_urls.length - 3} +
+ ` : ''} +
` : ''}