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 src/ ./src/
|
||||||
COPY migrations/ ./migrations/
|
RUN mkdir -p ./migrations
|
||||||
|
|
||||||
# 환경 변수 설정
|
# 환경 변수 설정
|
||||||
ENV PYTHONPATH=/app
|
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:
|
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,22 +127,30 @@ 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:
|
||||||
response_data.append({
|
image_urls_list = None
|
||||||
"id": todo.id,
|
if todo.image_urls:
|
||||||
"user_id": todo.user_id,
|
try:
|
||||||
"title": todo.title,
|
image_urls_list = json.loads(todo.image_urls)
|
||||||
"description": todo.description,
|
except json.JSONDecodeError:
|
||||||
"category": todo.category,
|
image_urls_list = None
|
||||||
"status": todo.status,
|
|
||||||
"created_at": todo.created_at,
|
response_data.append({
|
||||||
"updated_at": todo.updated_at,
|
"id": todo.id,
|
||||||
"due_date": todo.due_date.isoformat() if todo.due_date else None,
|
"user_id": todo.user_id,
|
||||||
"completed_at": todo.completed_at,
|
"title": todo.title,
|
||||||
"image_url": todo.image_url,
|
"description": todo.description,
|
||||||
"tags": todo.tags
|
"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
|
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 = {
|
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="이미지 추가에 실패했습니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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) # 태그 (쉼표로 구분)
|
||||||
|
|
||||||
# 관계
|
# 관계
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +340,103 @@
|
|||||||
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);
|
// 즉시 완료 상태로 렌더링
|
||||||
updateProgress();
|
renderChecklistItems(checklistItems);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// 진행률 업데이트
|
// 진행률 업데이트
|
||||||
function updateProgress() {
|
function updateProgress() {
|
||||||
const total = checklistItems.length;
|
const total = checklistItems.length;
|
||||||
|
|||||||
@@ -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>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
alt="첨부 이미지"
|
${item.image_urls.slice(0, 2).map(url => `
|
||||||
class="w-16 h-16 object-cover rounded-lg border border-gray-200 cursor-pointer hover:border-gray-400 transition-colors"
|
<img src="${url}"
|
||||||
onclick="showImagePreview('${item.image_url}', '${item.title}')"
|
alt="첨부 이미지"
|
||||||
title="클릭하여 크게 보기">
|
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>
|
</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,44 +1946,56 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사진 업로드 처리
|
// 개별 사진 제거
|
||||||
|
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);
|
||||||
|
|
||||||
// 이미지 압축 (ImageUtils가 있는 경우)
|
for (const file of filesToProcess) {
|
||||||
let processedImage;
|
// 이미지 압축 (ImageUtils가 있는 경우)
|
||||||
if (window.ImageUtils) {
|
let processedImage;
|
||||||
processedImage = await ImageUtils.compressImage(file, {
|
if (window.ImageUtils) {
|
||||||
maxWidth: 800,
|
processedImage = await ImageUtils.compressImage(file, {
|
||||||
maxHeight: 600,
|
maxWidth: 800,
|
||||||
quality: 0.8
|
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();
|
||||||
|
|
||||||
// 미리보기 표시
|
if (filesToProcess.length < files.length) {
|
||||||
const previewContainer = document.getElementById('photoPreview');
|
alert(`최대 5개까지만 업로드됩니다. ${filesToProcess.length}개가 추가되었습니다.`);
|
||||||
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)`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} 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('📸 이미지 데이터 없음');
|
||||||
|
|||||||
@@ -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>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user