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 src/ ./src/
COPY migrations/ ./migrations/ RUN mkdir -p ./migrations
# 환경 변수 설정 # 환경 변수 설정
ENV PYTHONPATH=/app 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: except ValueError:
logger.warning(f"Invalid date format: {todo_data.due_date}") 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 생성 # 새 Todo 생성
new_todo = Todo( new_todo = Todo(
user_id=current_user.id, user_id=current_user.id,
@@ -51,7 +57,7 @@ async def create_todo(
description=todo_data.description, description=todo_data.description,
category=todo_data.category, category=todo_data.category,
due_date=parsed_due_date, due_date=parsed_due_date,
image_url=todo_data.image_url, image_urls=image_urls_json,
tags=todo_data.tags tags=todo_data.tags
) )
@@ -60,6 +66,14 @@ async def create_todo(
await db.refresh(new_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 = { response_data = {
"id": new_todo.id, "id": new_todo.id,
"user_id": new_todo.user_id, "user_id": new_todo.user_id,
@@ -71,7 +85,7 @@ async def create_todo(
"updated_at": new_todo.updated_at, "updated_at": new_todo.updated_at,
"due_date": new_todo.due_date.isoformat() if new_todo.due_date else None, "due_date": new_todo.due_date.isoformat() if new_todo.due_date else None,
"completed_at": new_todo.completed_at, "completed_at": new_todo.completed_at,
"image_url": new_todo.image_url, "image_urls": image_urls_list,
"tags": new_todo.tags "tags": new_todo.tags
} }
@@ -113,8 +127,16 @@ async def get_todos(
todos = result.scalars().all() todos = result.scalars().all()
# 응답용 데이터 변환 # 응답용 데이터 변환
import json
response_data = [] response_data = []
for todo in todos: for todo in todos:
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({ response_data.append({
"id": todo.id, "id": todo.id,
"user_id": todo.user_id, "user_id": todo.user_id,
@@ -126,7 +148,7 @@ async def get_todos(
"updated_at": todo.updated_at, "updated_at": todo.updated_at,
"due_date": todo.due_date.isoformat() if todo.due_date else None, "due_date": todo.due_date.isoformat() if todo.due_date else None,
"completed_at": todo.completed_at, "completed_at": todo.completed_at,
"image_url": todo.image_url, "image_urls": image_urls_list,
"tags": todo.tags "tags": todo.tags
}) })
@@ -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 = { response_data = {
"id": todo.id, "id": todo.id,
"user_id": todo.user_id, "user_id": todo.user_id,
@@ -173,7 +203,7 @@ async def get_todo(
"updated_at": todo.updated_at, "updated_at": todo.updated_at,
"due_date": todo.due_date.isoformat() if todo.due_date else None, "due_date": todo.due_date.isoformat() if todo.due_date else None,
"completed_at": todo.completed_at, "completed_at": todo.completed_at,
"image_url": todo.image_url, "image_urls": image_urls_list,
"tags": todo.tags, "tags": todo.tags,
} }
@@ -213,6 +243,7 @@ async def update_todo(
) )
# 업데이트할 필드만 수정 # 업데이트할 필드만 수정
import json
update_data = todo_data.dict(exclude_unset=True) update_data = todo_data.dict(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
if field == 'due_date' and value: if field == 'due_date' and value:
@@ -222,6 +253,9 @@ async def update_todo(
setattr(todo, field, parsed_date) setattr(todo, field, parsed_date)
except ValueError: except ValueError:
logger.warning(f"Invalid date format: {value}") 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: else:
setattr(todo, field, value) setattr(todo, field, value)
@@ -235,6 +269,13 @@ async def update_todo(
await db.refresh(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 = { response_data = {
"id": todo.id, "id": todo.id,
"user_id": todo.user_id, "user_id": todo.user_id,
@@ -246,7 +287,7 @@ async def update_todo(
"updated_at": todo.updated_at, "updated_at": todo.updated_at,
"due_date": todo.due_date.isoformat() if todo.due_date else None, "due_date": todo.due_date.isoformat() if todo.due_date else None,
"completed_at": todo.completed_at, "completed_at": todo.completed_at,
"image_url": todo.image_url, "image_urls": image_urls_list,
"tags": todo.tags, "tags": todo.tags,
} }
@@ -300,3 +341,95 @@ async def delete_todo(
detail="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) 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) # 태그 (쉼표로 구분) tags = Column(String(500), nullable=True) # 태그 (쉼표로 구분)
# 관계 # 관계

View File

@@ -31,7 +31,7 @@ class TodoBase(BaseModel):
class TodoCreate(TodoBase): class TodoCreate(TodoBase):
"""Todo 생성""" """Todo 생성"""
due_date: Optional[str] = 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) tags: Optional[str] = Field(None, max_length=500)
@@ -42,7 +42,7 @@ class TodoUpdate(BaseModel):
category: Optional[TodoCategoryEnum] = None category: Optional[TodoCategoryEnum] = None
status: Optional[TodoStatusEnum] = None status: Optional[TodoStatusEnum] = None
due_date: Optional[str] = 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) tags: Optional[str] = Field(None, max_length=500)
@@ -57,7 +57,7 @@ class TodoResponse(BaseModel):
updated_at: datetime updated_at: datetime
due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식) due_date: Optional[str] = None # 문자열로 변경 (한국 시간 형식)
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
image_url: Optional[str] = None image_urls: Optional[List[str]] = None
tags: Optional[str] = None tags: Optional[str] = None
class Config: class Config:

View File

@@ -222,9 +222,18 @@
</div> </div>
<!-- 사진 (있는 경우) --> <!-- 사진 (있는 경우) -->
${item.photo ? ` ${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-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> </div>
` : ''} ` : ''}

View File

@@ -229,6 +229,19 @@
try { try {
// API에서 체크리스트 카테고리 항목들만 가져오기 // API에서 체크리스트 카테고리 항목들만 가져오기
const items = await TodoAPI.getTodos(null, 'checklist'); 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; checklistItems = items;
} catch (error) { } catch (error) {
console.error('체크리스트 항목 로드 실패:', error); console.error('체크리스트 항목 로드 실패:', error);
@@ -263,9 +276,18 @@
</div> </div>
<!-- 사진 (있는 경우) --> <!-- 사진 (있는 경우) -->
${item.photo ? ` ${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-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> </div>
` : ''} ` : ''}
@@ -318,15 +340,101 @@
function toggleComplete(id) { function toggleComplete(id) {
const item = checklistItems.find(item => item.id === id); const item = checklistItems.find(item => item.id === id);
if (item) { if (item) {
item.completed = !item.completed; if (!item.completed) {
item.completed_at = item.completed ? new Date().toISOString().split('T')[0] : null; // 완료 처리 - 애니메이션 후 제거
item.completed = true;
item.completed_at = new Date().toISOString().split('T')[0];
// 즉시 완료 상태로 렌더링
renderChecklistItems(checklistItems); renderChecklistItems(checklistItems);
updateProgress(); updateProgress();
// TODO: API 호출하여 상태 업데이트 // 1.5초 후 항목 제거
console.log('체크리스트 완료 상태 변경:', id, item.completed); 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);
} }
// 진행률 업데이트 // 진행률 업데이트

View File

@@ -351,9 +351,18 @@
</div> </div>
<!-- 사진 (있는 경우) --> <!-- 사진 (있는 경우) -->
${item.photo ? ` ${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-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> </div>
` : ''} ` : ''}

View File

@@ -540,7 +540,7 @@
<button type="button" onclick="selectFile()" class="text-blue-600 hover:text-blue-800 font-medium"> <button type="button" onclick="selectFile()" class="text-blue-600 hover:text-blue-800 font-medium">
파일 선택 파일 선택
</button> </button>
<input type="file" id="desktopFileInput" accept="image/*" class="hidden"> <input type="file" id="desktopFileInput" accept="image/*" multiple class="hidden">
</div> </div>
</div> </div>
@@ -556,8 +556,8 @@
<p class="text-sm text-gray-600">갤러리</p> <p class="text-sm text-gray-600">갤러리</p>
</button> </button>
</div> </div>
<input type="file" id="cameraInput" accept="image/*" capture="camera" class="hidden"> <input type="file" id="cameraInput" accept="image/*" capture="camera" multiple class="hidden">
<input type="file" id="galleryInput" accept="image/*" class="hidden"> <input type="file" id="galleryInput" accept="image/*" multiple class="hidden">
</div> </div>
<!-- 사진 미리보기 --> <!-- 사진 미리보기 -->
@@ -750,21 +750,23 @@
console.log(`🔍 원본 API 데이터 ${index + 1}:`, { console.log(`🔍 원본 API 데이터 ${index + 1}:`, {
id: todo.id, id: todo.id,
title: todo.title, 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 category: todo.category
}); });
}); });
// 체크리스트 타입만 필터링 // 체크리스트 타입만 필터링 (완료되지 않은 항목만)
checklistData = todos checklistData = todos
.filter(todo => todo.category === 'checklist') .filter(todo => todo.category === 'checklist' && todo.status !== 'completed')
.map(todo => ({ .map(todo => ({
id: todo.id, id: todo.id,
title: todo.title, title: todo.title,
description: todo.description, description: todo.description,
created_at: todo.created_at, created_at: todo.created_at,
completed: todo.status === 'completed', completed: todo.status === 'completed',
image_url: todo.image_url // 이미지 URL 포함 image_urls: todo.image_urls || [] // 다중 이미지 URL 배열
})); }));
console.log('체크리스트 데이터 로드 완료:', checklistData); console.log('체크리스트 데이터 로드 완료:', checklistData);
@@ -935,8 +937,9 @@
console.log(`📝 체크리스트 항목 ${index + 1}:`, { console.log(`📝 체크리스트 항목 ${index + 1}:`, {
id: item.id, id: item.id,
title: item.title, title: item.title,
image_url: item.image_url ? '이미지 있음' : '이미지 없음', image_urls: item.image_urls,
image_length: item.image_url ? item.image_url.length : 0 image_urls_type: typeof item.image_urls,
image_urls_length: item.image_urls ? item.image_urls.length : 'null'
}); });
return ` return `
@@ -946,13 +949,22 @@
<div class="flex-1 min-w-0 mr-4"> <div class="flex-1 min-w-0 mr-4">
<div class="flex items-start space-x-3"> <div class="flex items-start space-x-3">
<!-- 이미지 썸네일 (있는 경우) --> <!-- 이미지 썸네일 (있는 경우) -->
${item.image_url ? ` ${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img src="${item.image_url}" <div class="flex space-x-1">
${item.image_urls.slice(0, 2).map(url => `
<img src="${url}"
alt="첨부 이미지" alt="첨부 이미지"
class="w-16 h-16 object-cover rounded-lg border border-gray-200 cursor-pointer hover:border-gray-400 transition-colors" class="w-12 h-12 object-cover rounded border border-gray-200 cursor-pointer hover:border-gray-400 transition-colors"
onclick="showImagePreview('${item.image_url}', '${item.title}')" onclick="showImagePreview('${url}', '${item.title}')"
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> </div>
` : ''} ` : ''}
@@ -962,7 +974,7 @@
${item.description ? `<p class="text-sm text-gray-600 mb-2">${item.description}</p>` : ''} ${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"> <div class="flex items-center space-x-3 text-sm text-gray-500">
<span>등록: ${formatDate(item.created_at)}</span> <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> </div>
</div> </div>
@@ -1007,21 +1019,74 @@
// 항목 완료 처리 // 항목 완료 처리
async function completeItem(itemId) { async function completeItem(itemId) {
try { 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, { await TodoAPI.updateTodo(itemId, {
status: 'completed' status: 'completed'
}); });
alert('항목이 완료 처리되었습니다!');
// 대시보드 데이터 새로고침
await initializeDashboard();
} catch (error) { } catch (error) {
console.error('완료 처리 실패:', error); console.error('완료 처리 실패:', error);
alert('완료 처리에 실패했습니다.'); 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로 변경 // Todo로 변경
function convertToTodo(itemId) { function convertToTodo(itemId) {
const item = checklistData.find(item => item.id === itemId); const item = checklistData.find(item => item.id === itemId);
@@ -1174,9 +1239,15 @@
} }
try { 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]) { if (fileInput.files && fileInput.files[0]) {
const file = fileInput.files[0]; const file = fileInput.files[0];
@@ -1187,7 +1258,8 @@
} }
// Base64로 인코딩하여 저장 // Base64로 인코딩하여 저장
imageUrl = await convertFileToBase64(file); newImageUrl = await convertFileToBase64(file);
console.log('🖼️ 새 이미지 추가:', newImageUrl ? '성공' : '실패');
} }
const updateData = { const updateData = {
@@ -1205,9 +1277,19 @@
console.log('변환 모달 날짜 설정 - 선택된 날짜 (KST):', date, '저장:', kstDateTime); console.log('변환 모달 날짜 설정 - 선택된 날짜 (KST):', date, '저장:', kstDateTime);
} }
// 이미지가 있으면 추가 // 이미지 배열 처리: 기존 이미지 + 새 이미지 (최대 5개)
if (imageUrl) { let finalImages = [...existingImages];
updateData.image_url = imageUrl; 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); await TodoAPI.updateTodo(itemId, updateData);
@@ -1313,10 +1395,7 @@
const info = categoryInfo[item.category] || categoryInfo['todo']; const info = categoryInfo[item.category] || categoryInfo['todo'];
// 이미지 파일 확인 // 이미지 파일 확인
const isImage = item.image_url && ( const hasImages = item.image_urls && item.image_urls.length > 0;
item.image_url.includes('data:image/') ||
/\.(jpg|jpeg|png|gif|webp)$/i.test(item.image_url)
);
// 첨부 파일 확인 및 타입 분류 // 첨부 파일 확인 및 타입 분류
const hasFile = item.file_url && item.file_name; const hasFile = item.file_url && item.file_name;
@@ -1431,11 +1510,22 @@
<!-- 이미지 미리보기 --> <!-- 이미지 미리보기 -->
${isImage ? ` ${hasImages ? `
<div class="mb-4"> <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"> <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>
</div> </div>
` : ''} ` : ''}
@@ -1807,7 +1897,7 @@
// 업로드 모달 관련 변수 // 업로드 모달 관련 변수
let currentPhoto = null; let currentPhotos = [];
// 업로드 모달 열기 // 업로드 모달 열기
function openUploadModal() { function openUploadModal() {
@@ -1825,7 +1915,7 @@
// 업로드 폼 초기화 // 업로드 폼 초기화
function clearUploadForm() { function clearUploadForm() {
document.getElementById('uploadForm').reset(); document.getElementById('uploadForm').reset();
removePhoto(); removeAllPhotos();
} }
// 파일 선택 (데스크톱) // 파일 선택 (데스크톱)
@@ -1843,19 +1933,10 @@
document.getElementById('galleryInput').click(); document.getElementById('galleryInput').click();
} }
// 사진 제거 // 모든 사진 제거
function removePhoto() { function removeAllPhotos() {
currentPhoto = null; currentPhotos = [];
const previewContainer = document.getElementById('photoPreview'); updatePhotoPreview();
const previewImage = document.getElementById('previewImage');
if (previewContainer) {
previewContainer.classList.add('hidden');
}
if (previewImage) {
previewImage.src = '';
}
// 파일 입력 초기화 // 파일 입력 초기화
const inputs = ['desktopFileInput', 'cameraInput', 'galleryInput']; const inputs = ['desktopFileInput', 'cameraInput', 'galleryInput'];
@@ -1865,16 +1946,32 @@
}); });
} }
// 사진 업로드 처리 // 개별 사진 제거
function removePhoto(index) {
if (index >= 0 && index < currentPhotos.length) {
currentPhotos.splice(index, 1);
updatePhotoPreview();
}
}
// 사진 업로드 처리 (다중 이미지 지원)
async function handlePhotoUpload(event) { async function handlePhotoUpload(event) {
const files = event.target.files; const files = event.target.files;
if (!files || files.length === 0) return; 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 { try {
showLoading(true); showLoading(true);
for (const file of filesToProcess) {
// 이미지 압축 (ImageUtils가 있는 경우) // 이미지 압축 (ImageUtils가 있는 경우)
let processedImage; let processedImage;
if (window.ImageUtils) { if (window.ImageUtils) {
@@ -1888,21 +1985,17 @@
processedImage = await fileToBase64(file); processedImage = await fileToBase64(file);
} }
currentPhoto = processedImage; currentPhotos.push({
data: processedImage,
// 미리보기 표시 name: file.name,
const previewContainer = document.getElementById('photoPreview'); size: file.size
const previewImage = document.getElementById('previewImage'); });
const photoInfo = document.getElementById('photoInfo');
if (previewContainer && previewImage) {
previewImage.src = processedImage;
previewContainer.classList.remove('hidden');
} }
if (photoInfo) { updatePhotoPreview();
const fileSize = Math.round(file.size / 1024);
photoInfo.textContent = `${file.name} (${fileSize}KB)`; if (filesToProcess.length < files.length) {
alert(`최대 5개까지만 업로드됩니다. ${filesToProcess.length}개가 추가되었습니다.`);
} }
} catch (error) { } 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로 변환 // 파일을 Base64로 변환
function fileToBase64(file) { function fileToBase64(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -1927,7 +2058,7 @@
async function handleUploadSubmit(event) { async function handleUploadSubmit(event) {
event.preventDefault(); event.preventDefault();
console.log('🚀 업로드 시작 - currentPhoto:', currentPhoto ? `있음 (${currentPhoto.length}자)` : '없음'); console.log('🚀 업로드 시작 - currentPhotos:', currentPhotos.length > 0 ? `${currentPhotos.length}개 이미지` : '이미지 없음');
const content = document.getElementById('uploadContent').value.trim(); const content = document.getElementById('uploadContent').value.trim();
if (!content) { if (!content) {
@@ -1940,7 +2071,7 @@
const itemData = { const itemData = {
content: content, content: content,
photo: currentPhoto, image_urls: currentPhotos.map(photo => photo.data),
created_at: new Date().toISOString() created_at: new Date().toISOString()
}; };
@@ -1951,13 +2082,13 @@
category: 'checklist' // 체크리스트로 바로 저장 category: 'checklist' // 체크리스트로 바로 저장
}; };
// 이미지가 있을 때만 image_url 추가 // 이미지가 있을 때만 image_urls 추가
if (currentPhoto) { if (currentPhotos.length > 0) {
todoData.image_url = currentPhoto; todoData.image_urls = currentPhotos.map(photo => photo.data);
console.log('📸 이미지 데이터 포함:', { console.log('📸 이미지 데이터 포함:', {
hasImage: true, hasImage: true,
imageLength: currentPhoto.length, imageCount: currentPhotos.length,
imagePreview: currentPhoto.substring(0, 50) + '...' totalSize: currentPhotos.reduce((sum, photo) => sum + photo.size, 0)
}); });
} else { } else {
console.log('📸 이미지 데이터 없음'); console.log('📸 이미지 데이터 없음');

View File

@@ -197,9 +197,18 @@
</div> </div>
<!-- 사진 (있는 경우) --> <!-- 사진 (있는 경우) -->
${item.photo ? ` ${item.image_urls && item.image_urls.length > 0 ? `
<div class="flex-shrink-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> </div>
` : ''} ` : ''}