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:

View File

@@ -222,9 +222,18 @@
</div>
<!-- 사진 (있는 경우) -->
${item.photo ? `
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<img src="${item.photo}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
<div class="flex space-x-2">
${item.image_urls.slice(0, 3).map(url => `
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
`).join('')}
${item.image_urls.length > 3 ? `
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
</div>
` : ''}
</div>
</div>
` : ''}

View File

@@ -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 @@
</div>
<!-- 사진 (있는 경우) -->
${item.photo ? `
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<img src="${item.photo}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
<div class="flex space-x-2">
${item.image_urls.slice(0, 3).map(url => `
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
`).join('')}
${item.image_urls.length > 3 ? `
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
</div>
` : ''}
</div>
</div>
` : ''}
@@ -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 = `
<div class="flex items-center space-x-2">
<i class="fas fa-check-circle"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
// 애니메이션으로 나타나기
setTimeout(() => {
toast.classList.remove('translate-x-full');
}, 100);
// 3초 후 사라지기
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
}
// 진행률 업데이트
function updateProgress() {
const total = checklistItems.length;

View File

@@ -351,9 +351,18 @@
</div>
<!-- 사진 (있는 경우) -->
${item.photo ? `
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<img src="${item.photo}" class="w-20 h-20 object-cover rounded-lg" alt="첨부 사진">
<div class="flex space-x-2">
${item.image_urls.slice(0, 2).map(url => `
<img src="${url}" class="w-20 h-20 object-cover rounded-lg" alt="첨부 사진">
`).join('')}
${item.image_urls.length > 2 ? `
<div class="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center">
<span class="text-sm text-gray-500">+${item.image_urls.length - 2}</span>
</div>
` : ''}
</div>
</div>
` : ''}

View File

@@ -540,7 +540,7 @@
<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">
<input type="file" id="desktopFileInput" accept="image/*" multiple class="hidden">
</div>
</div>
@@ -556,8 +556,8 @@
<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">
<input type="file" id="cameraInput" accept="image/*" capture="camera" multiple class="hidden">
<input type="file" id="galleryInput" accept="image/*" multiple class="hidden">
</div>
<!-- 사진 미리보기 -->
@@ -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 @@
<div class="flex-1 min-w-0 mr-4">
<div class="flex items-start space-x-3">
<!-- 이미지 썸네일 (있는 경우) -->
${item.image_url ? `
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<img src="${item.image_url}"
alt="첨부 이미지"
class="w-16 h-16 object-cover rounded-lg border border-gray-200 cursor-pointer hover:border-gray-400 transition-colors"
onclick="showImagePreview('${item.image_url}', '${item.title}')"
title="클릭하여 크게 보기">
<div class="flex space-x-1">
${item.image_urls.slice(0, 2).map(url => `
<img src="${url}"
alt="첨부 이미지"
class="w-12 h-12 object-cover rounded border border-gray-200 cursor-pointer hover:border-gray-400 transition-colors"
onclick="showImagePreview('${url}', '${item.title}')"
title="클릭하여 크게 보기">
`).join('')}
${item.image_urls.length > 2 ? `
<div class="w-12 h-12 bg-gray-100 rounded flex items-center justify-center border border-gray-200">
<span class="text-xs text-gray-500">+${item.image_urls.length - 2}</span>
</div>
` : ''}
</div>
</div>
` : ''}
@@ -962,7 +974,7 @@
${item.description ? `<p class="text-sm text-gray-600 mb-2">${item.description}</p>` : ''}
<div class="flex items-center space-x-3 text-sm text-gray-500">
<span>등록: ${formatDate(item.created_at)}</span>
${item.image_url ? `<span class="flex items-center text-blue-600"><i class="fas fa-image mr-1"></i>이미지</span>` : ''}
${item.image_urls && item.image_urls.length > 0 ? `<span class="flex items-center text-blue-600"><i class="fas fa-image mr-1"></i>이미지 ${item.image_urls.length}</span>` : ''}
</div>
</div>
</div>
@@ -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 = `
<div class="flex items-center space-x-2">
<i class="fas fa-check-circle"></i>
<span>${message}</span>
</div>
`;
document.body.appendChild(toast);
// 애니메이션으로 나타나기
setTimeout(() => {
toast.classList.remove('translate-x-full');
}, 100);
// 3초 후 사라지기
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
}
// 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 ? `
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">첨부 이미지</label>
<label class="block text-sm font-medium text-gray-700 mb-2">첨부 이미지 (${item.image_urls.length}개)</label>
<div class="border border-gray-200 rounded-lg p-4">
<img src="${item.image_url}" alt="첨부 이미지" class="max-w-full h-auto rounded-lg shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
${item.image_urls.map((url, index) => `
<div class="relative">
<img src="${url}" alt="첨부 이미지 ${index + 1}"
class="w-full h-48 object-cover rounded-lg shadow-sm cursor-pointer hover:shadow-md transition-shadow"
onclick="showImagePreview('${url}', '${item.title} - 이미지 ${index + 1}')">
<div class="absolute top-2 right-2 bg-black bg-opacity-50 text-white text-xs px-2 py-1 rounded">
${index + 1}/${item.image_urls.length}
</div>
</div>
`).join('')}
</div>
</div>
</div>
` : ''}
@@ -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 = `
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">업로드된 이미지 (${currentPhotos.length}/5)</span>
<button type="button" onclick="removeAllPhotos()" class="text-red-600 hover:text-red-800 text-sm">
전체 삭제
</button>
</div>
<div class="grid grid-cols-2 gap-3">
${currentPhotos.map((photo, index) => `
<div class="relative">
<img src="${photo.data}" class="w-full h-24 object-cover rounded-lg" alt="${photo.name}">
<button type="button" onclick="removePhoto(${index})"
class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600">
<i class="fas fa-times text-xs"></i>
</button>
<div class="mt-1 text-xs text-gray-500 truncate">${photo.name}</div>
</div>
`).join('')}
</div>
</div>
`;
}
}
// 파일을 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('📸 이미지 데이터 없음');

View File

@@ -197,9 +197,18 @@
</div>
<!-- 사진 (있는 경우) -->
${item.photo ? `
${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0">
<img src="${item.photo}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
<div class="flex space-x-2">
${item.image_urls.slice(0, 3).map(url => `
<img src="${url}" class="w-16 h-16 object-cover rounded-lg" alt="첨부 사진">
`).join('')}
${item.image_urls.length > 3 ? `
<div class="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center">
<span class="text-xs text-gray-500">+${item.image_urls.length - 3}</span>
</div>
` : ''}
</div>
</div>
` : ''}