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:
@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir -e .
|
||||
|
||||
# 애플리케이션 코드 복사
|
||||
COPY src/ ./src/
|
||||
COPY migrations/ ./migrations/
|
||||
RUN mkdir -p ./migrations
|
||||
|
||||
# 환경 변수 설정
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
19
backend/migrations/001_add_multiple_images.sql
Normal file
19
backend/migrations/001_add_multiple_images.sql
Normal 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));
|
||||
@@ -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="이미지 추가에 실패했습니다."
|
||||
)
|
||||
|
||||
|
||||
@@ -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) # 태그 (쉼표로 구분)
|
||||
|
||||
# 관계
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
` : ''}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
` : ''}
|
||||
|
||||
|
||||
@@ -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('📸 이미지 데이터 없음');
|
||||
|
||||
@@ -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>
|
||||
` : ''}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user