diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java index 1fdac22..ee8db0b 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/controller/ImageController.java @@ -11,6 +11,8 @@ import java.io.File; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -21,8 +23,7 @@ public class ImageController { @Value("${file.upload-dir}") private String uploadDir; - // consumes 제거 - // Swagger용 어노테이션 추가 (파일 선택 버튼 표시용) + // 단일 이미지 업로드 @PostMapping @Operation( requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( @@ -33,33 +34,44 @@ public class ImageController { public ResponseEntity> uploadImage( @RequestParam("file") MultipartFile file ) throws IOException { + String savedUrl = saveFile(file); + return ResponseEntity.ok(Map.of("imageUrl", savedUrl)); + } - // 절대 경로로 변환 - File dir = new File(uploadDir).getAbsoluteFile(); - if (!dir.exists()) { - dir.mkdirs(); + // 다중 이미지 업로드 (최대 5장) + // POST /api/images/multi + @PostMapping("/multi") + @Operation( + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + schema = @Schema(type = "object", requiredProperties = {"files"})) + ) + ) + public ResponseEntity>> uploadImages( + @RequestParam("files") List files + ) throws IOException { + if (files == null || files.isEmpty()) { + return ResponseEntity.badRequest().build(); } - - // 파일명 중복 방지: UUID + 원본 확장자 - String originalName = file.getOriginalFilename(); - String extension = ""; - if (originalName != null && originalName.contains(".")) { - extension = originalName.substring(originalName.lastIndexOf(".")); + // 최대 5장 제한 (서버 부하 방지) + if (files.size() > 5) { + return ResponseEntity.badRequest().build(); } - String savedName = UUID.randomUUID() + extension; - - // 절대 경로로 파일 저장 - File targetFile = new File(dir, savedName); - file.transferTo(targetFile); - return ResponseEntity.ok(Map.of("imageUrl", "/api/images/" + savedName)); + List imageUrls = new ArrayList<>(); + for (MultipartFile file : files) { + if (!file.isEmpty()) { + imageUrls.add(saveFile(file)); + } + } + return ResponseEntity.ok(Map.of("imageUrls", imageUrls)); } // 이미지 조회 // GET /api/images/{filename} @GetMapping("/{filename}") public ResponseEntity getImage(@PathVariable String filename) throws IOException { - File file = new File(new File(uploadDir).getAbsoluteFile(), filename); // ← 절대 경로 + File file = new File(new File(uploadDir).getAbsoluteFile(), filename); if (!file.exists()) { return ResponseEntity.notFound().build(); @@ -71,4 +83,24 @@ public ResponseEntity getImage(@PathVariable String filename) throws IOE .header("Content-Type", contentType) .body(java.nio.file.Files.readAllBytes(file.toPath())); } + + // 파일 저장 공통 로직 + private String saveFile(MultipartFile file) throws IOException { + File dir = new File(uploadDir).getAbsoluteFile(); + if (!dir.exists()) { + dir.mkdirs(); + } + + String originalName = file.getOriginalFilename(); + String extension = ""; + if (originalName != null && originalName.contains(".")) { + extension = originalName.substring(originalName.lastIndexOf(".")); + } + String savedName = UUID.randomUUID() + extension; + + File targetFile = new File(dir, savedName); + file.transferTo(targetFile); + + return "/api/images/" + savedName; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java index c49ab0c..4d04cc7 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionReqDTO.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.List; + public class QuestionReqDTO { // 질문 등록 요청 @@ -11,7 +13,9 @@ public class QuestionReqDTO { @NoArgsConstructor public static class CreateReq { private String content; - private String imageUrl; + // 이미지 여러 장 지원: URL 목록으로 수신 + // 프론트에서 /api/images/multi로 업로드 후 반환된 URL 목록을 넘겨줌 + private List imageUrls; } // 질문 수정 요청 @@ -27,7 +31,8 @@ public static class UpdateReq { @NoArgsConstructor public static class CommentReq { private String content; - private String imageUrl; + // 이미지 여러 장 지원 + private List imageUrls; private Long parentCommentId; // 대댓글일 때만 값이 있음, 일반 댓글이면 null } diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java index fd59783..7c516fd 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/dto/QuestionResDTO.java @@ -75,7 +75,8 @@ public record QuestionDetailResponse( Long questionId, String displayName, String content, - String imageUrl, + // 이미지 여러 장 지원: URL 목록 + List imageUrls, Boolean isResolved, Boolean isPopular, Integer likeCount, @@ -91,7 +92,8 @@ public record CommentResponse( Long commentId, String displayName, String content, - String imageUrl, + // 이미지 여러 장 지원 + List imageUrls, Boolean isMine, LocalDateTime createdAt, List replies @@ -161,7 +163,8 @@ public record QuestionGroupsResponse( public record QuestionSummaryResponse( Long questionId, String content, - String imageUrl, + // 이미지 여러 장 지원 + List imageUrls, Boolean isResolved, Boolean isPopular, Boolean isLiked, @@ -245,7 +248,8 @@ public record QuestionCreatedEvent( Long sessionId, Long questionId, String content, - String imageUrl, + // 이미지 여러 장 지원 + List imageUrls, // 좋아요 수 (생성 직후에는 0) Integer likeCount, // 댓글 수 (생성 직후에는 0) diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java index 195f34c..24e3197 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/Question.java @@ -6,6 +6,10 @@ import lombok.*; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; @Entity @Table(name = "question") @@ -30,6 +34,12 @@ public class Question { @Column(columnDefinition = "TEXT") private String content; + /* + 이미지 URL 목록을 JSON 배열 문자열로 저장 + 예시: ["\/api\/images\/uuid1.png","\/api\/images\/uuid2.jpg"] + 기존 단일 URL(하위 호환): 기존 데이터에 imageUrl이 JSON 배열이 아닌 단일 URL 문자열로 저장된 경우 + getImageUrls()에서 정상적으로 파싱하여 1개짜리 리스트로 반환 + */ @Column(name = "image_url", columnDefinition = "TEXT") private String imageUrl; @@ -48,6 +58,18 @@ public class Question { @Column(name = "deleted_at") private LocalDateTime deletedAt; + // 이미지 URL 목록 조회 (JSON 배열 → List 변환) + @Transient + public List getImageUrls() { + return parseImageUrls(this.imageUrl); + } + + // 이미지 URL 목록 저장 (List → JSON 배열 문자열 변환) + public void setImageUrls(List imageUrls) { + this.imageUrl = serializeImageUrls(imageUrls); + this.updatedAt = LocalDateTime.now(); + } + // 댓글이 새로 달리면 미해결로 되돌리도록 public void markUnresolved() { this.isResolved = false; @@ -85,4 +107,37 @@ public void markResolved() { this.isResolved = true; this.updatedAt = LocalDateTime.now(); } + + // JSON 배열 문자열 파싱 유틸 (하위 호환: 기존 단일 URL도 1개짜리 리스트로 반환) + public static List parseImageUrls(String raw) { + if (raw == null || raw.isBlank()) { + return new ArrayList<>(); + } + String trimmed = raw.trim(); + // JSON 배열 형태인 경우 + if (trimmed.startsWith("[")) { + // 간단한 JSON 배열 파싱 (외부 라이브러리 없이) + String inner = trimmed.substring(1, trimmed.length() - 1).trim(); + if (inner.isEmpty()) return new ArrayList<>(); + return Arrays.stream(inner.split(",")) + .map(s -> s.trim().replaceAll("^\"|\"$", "")) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + // 기존 단일 URL (하위 호환) + List list = new ArrayList<>(); + list.add(trimmed); + return list; + } + + // List → JSON 배열 문자열 직렬화 유틸 + public static String serializeImageUrls(List urls) { + if (urls == null || urls.isEmpty()) { + return null; + } + String joined = urls.stream() + .map(url -> "\"" + url.replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(",")); + return "[" + joined + "]"; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java index d91606c..b68b716 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/entity/QuestionComment.java @@ -5,6 +5,7 @@ import lombok.*; import java.time.LocalDateTime; +import java.util.List; @Entity @Table(name = "question_comment") @@ -39,6 +40,10 @@ public class QuestionComment { @Column(columnDefinition = "TEXT") private String content; + /* + 이미지 URL 목록을 JSON 배열 문자열로 저장 + Question 엔티티와 동일한 방식 사용 + */ @Column(name = "image_url", columnDefinition = "TEXT") private String imageUrl; @@ -51,6 +56,12 @@ public class QuestionComment { @Column(name = "deleted_at") private LocalDateTime deletedAt; + // 이미지 URL 목록 조회 + @Transient + public List getImageUrls() { + return Question.parseImageUrls(this.imageUrl); + } + // 댓글 내용 수정 public void updateContent(String content) { this.content = content; diff --git a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java index 74552f7..008597d 100644 --- a/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java +++ b/backend/src/main/java/com/example/Piroin/project/domain/question/service/QuestionService.java @@ -92,7 +92,7 @@ private QuestionResDTO.QuestionDetailResponse toDetailResponse(Question question .toList(); return new QuestionResDTO.QuestionDetailResponse( - question.getId(), "작성자", question.getContent(), question.getImageUrl(), + question.getId(), "작성자", question.getContent(), question.getImageUrls(), question.getIsResolved(), isPopular, question.getLikeCount(), isLiked, isMine, question.getCreatedAt(), commentResponses @@ -106,14 +106,14 @@ private QuestionResDTO.CommentResponse toCommentResponse(Question question, Ques List replyResponses = replies.stream() .map(reply -> new QuestionResDTO.CommentResponse( reply.getId(), getDisplayName(question, reply.getUser()), - reply.getContent(), reply.getImageUrl(), isCommentMine(reply, loginUser), + reply.getContent(), reply.getImageUrls(), isCommentMine(reply, loginUser), reply.getCreatedAt(), List.of() )) .toList(); return new QuestionResDTO.CommentResponse( comment.getId(), getDisplayName(question, comment.getUser()), - comment.getContent(), comment.getImageUrl(), isCommentMine(comment, loginUser), + comment.getContent(), comment.getImageUrls(), isCommentMine(comment, loginUser), comment.getCreatedAt(), replyResponses ); } @@ -137,7 +137,7 @@ public QuestionResDTO.CommentCreateRes createComment( QuestionComment parentComment = resolveParentComment(request.getParentCommentId(), question); // builder 전에 검증 추가 - validateCommentContent(request.getContent(), request.getImageUrl()); + validateCommentContent(request.getContent(), request.getImageUrls()); // 2. 댓글 엔티티 생성 및 저장 LocalDateTime now = LocalDateTime.now(); @@ -146,7 +146,7 @@ public QuestionResDTO.CommentCreateRes createComment( .user(loginUser) .parentComment(parentComment) // 일반 댓글이면 null, 대댓글이면 부모 댓글 .content(request.getContent()) - .imageUrl(request.getImageUrl()) + .imageUrl(Question.serializeImageUrls(request.getImageUrls())) .createdAt(now) .updatedAt(now) .build(); @@ -252,13 +252,13 @@ public QuestionResDTO.CreateRes createQuestion(Long sessionId, QuestionReqDTO.Cr StudySession session = findSession(sessionId); // builder 전에 검증 추가 - validateQuestionContent(request.getContent(), request.getImageUrl()); + validateQuestionContent(request.getContent(), request.getImageUrls()); Question question = Question.builder() .session(session) .user(loginUser) .content(request.getContent()) - .imageUrl(request.getImageUrl()) + .imageUrl(Question.serializeImageUrls(request.getImageUrls())) .isResolved(false) .likeCount(0) .createdAt(LocalDateTime.now()) @@ -654,7 +654,7 @@ private QuestionResDTO.QuestionSummaryResponse toQuestionSummaryResponse ( boolean isLiked = summaryContext.likedQuestionIds().contains(questionId); boolean isMine = question.getUser().getId().equals(loginUser.getId()); return new QuestionResDTO.QuestionSummaryResponse( - questionId, question.getContent(), question.getImageUrl(), + questionId, question.getContent(), question.getImageUrls(), question.getIsResolved(), !question.getIsResolved() && question.getLikeCount() >= POPULAR_LIKE_THRESHOLD, isLiked, @@ -713,6 +713,7 @@ private QuestionResDTO.PreviewCommentResponse toPreviewCommentResponse( } private boolean hasPreviewImage(QuestionCommentRepository.PreviewCommentRow row) { + // image_url 컬럼에 값이 있으면 이미지 있는 것으로 처리 (JSON 배열 또는 단일 URL 모두 포함) return row.getImageUrl() != null && !row.getImageUrl().isBlank(); } @@ -789,7 +790,7 @@ private void publishQuestionCreatedEventAfterCommit(Question question) { sessionId, question.getId(), question.getContent(), - question.getImageUrl(), + question.getImageUrls(), question.getLikeCount(), 0, // 방금 만들어진 질문이므로 댓글 수는 0 question.getCreatedAt() @@ -871,18 +872,18 @@ private record QuestionSummaryContext( } // 질문은 내용 또는 이미지 중 하나는 반드시 있어야 함 - private void validateQuestionContent(String content, String imageUrl) { + private void validateQuestionContent(String content, List imageUrls) { boolean hasContent = content != null && !content.isBlank(); - boolean hasImage = imageUrl != null && !imageUrl.isBlank(); + boolean hasImage = imageUrls != null && !imageUrls.isEmpty(); if (!hasContent && !hasImage) { throw new QuestionException(HttpStatus.BAD_REQUEST, "질문 내용 또는 이미지 중 하나는 필수입니다."); } } // 댓글은 내용 또는 이미지 중 하나는 반드시 있어야 함 - private void validateCommentContent(String content, String imageUrl) { + private void validateCommentContent(String content, List imageUrls) { boolean hasContent = content != null && !content.isBlank(); - boolean hasImage = imageUrl != null && !imageUrl.isBlank(); + boolean hasImage = imageUrls != null && !imageUrls.isEmpty(); if (!hasContent && !hasImage) { throw new QuestionException(HttpStatus.BAD_REQUEST, "댓글 내용 또는 이미지 중 하나는 필수입니다."); } diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index 9ca6e44..e965efb 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -8,7 +8,7 @@ import { MeCuriousToo, StaffCheck, SumitBtn, - uploadImage, + uploadImages, } from '../../utils/qnaUtils'; import profileImg from '../../assets/images/profile.png'; import { authFetch } from '../../utils/Api'; @@ -39,10 +39,16 @@ const createBlobImageUrl = async (imageUrl) => { } }; +// imageUrls 배열을 blob URL 배열로 변환 +const createBlobImageUrls = async (imageUrls) => { + if (!imageUrls || imageUrls.length === 0) return []; + return Promise.all(imageUrls.map(url => createBlobImageUrl(url))); +}; + const attachCommentBlobImages = async (comments = []) => Promise.all( comments.map(async (comment) => ({ ...comment, - imageUrl: await createBlobImageUrl(comment.imageUrl), + imageUrls: await createBlobImageUrls(comment.imageUrls ?? []), replies: await attachCommentBlobImages(comment.replies ?? []), })) ); @@ -82,8 +88,8 @@ function QnADetailPage() { // ── 댓글 입력 상태 ─────────────────────────────── const [commentText, setCommentText] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - const [selectedImage, setSelectedImage] = useState(null); - const [imagePreview, setImagePreview] = useState(null); + const [selectedImages, setSelectedImages] = useState([]); // 여러 장 + const [imagePreviews, setImagePreviews] = useState([]); // 여러 장 미리보기 const fileInputRef = useRef(null); // ── 댓글 수정 상태 ─────────────────────────────── @@ -104,8 +110,8 @@ function QnADetailPage() { const result = json.result; - // 질문 이미지 blob 변환 - result.imageUrl = await createBlobImageUrl(result.imageUrl); + // 질문 이미지 blob 변환 (여러 장) + result.imageUrls = await createBlobImageUrls(result.imageUrls ?? []); // 댓글과 대댓글 이미지 blob 변환 if (result.comments) { @@ -259,10 +265,14 @@ function QnADetailPage() { // ── 댓글 이미지 선택 / 붙여넣기 ───────────────── const handleImageSelect = (e) => { - const file = e.target.files[0]; - if (!file) return; - setSelectedImage(file); - setImagePreview(URL.createObjectURL(file)); + const files = Array.from(e.target.files); + if (!files.length) return; + // 최대 5장 제한 + const merged = [...selectedImages, ...files].slice(0, 5); + setSelectedImages(merged); + setImagePreviews(merged.map(f => URL.createObjectURL(f))); + // 같은 파일 재선택 허용 + e.target.value = ''; }; const handlePaste = (e) => { @@ -272,49 +282,44 @@ function QnADetailPage() { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { - setSelectedImage(file); - setImagePreview(URL.createObjectURL(file)); + const merged = [...selectedImages, file].slice(0, 5); + setSelectedImages(merged); + setImagePreviews(merged.map(f => URL.createObjectURL(f))); } break; } } }; + const handleRemoveImage = (idx) => { + const next = selectedImages.filter((_, i) => i !== idx); + setSelectedImages(next); + setImagePreviews(next.map(f => URL.createObjectURL(f))); + }; + // ── 댓글 등록 ──────────────────────────────────── const handleCommentSubmit = async () => { const text = commentText.trim(); - if (!text && !selectedImage) return; + if (!text && selectedImages.length === 0) return; setIsSubmitting(true); try { - let imageUrl = null; - if (selectedImage) { - imageUrl = await uploadImage(selectedImage); + let imageUrls = []; + if (selectedImages.length > 0) { + imageUrls = await uploadImages(selectedImages); } const res = await authFetch(`/api/questions/${questionId}/comments`, { method: 'POST', - body: JSON.stringify({ content: text, parentCommentId: null, imageUrl }), + body: JSON.stringify({ content: text, parentCommentId: null, imageUrls }), }); if (!res.ok) throw new Error(); const json = await res.json(); if (json.isSuccess) { - // 댓글 등록 응답값으로 해결 상태 반영 - setQuestion(prev => ({ ...prev, isResolved: json.result.isResolved })); - - const newComment = { - commentId: json.result.commentId, - displayName: json.result.displayName, - content: json.result.content, - createdAt: json.result.createdAt, - imageUrl: imagePreview, - isMine: true, - }; - setQuestion(prev => ({ - ...prev, - comments: [...(prev.comments ?? []), newComment], - })); setCommentText(''); - setSelectedImage(null); - setImagePreview(null); + setSelectedImages([]); + setImagePreviews([]); + // 로컬 상태에 blob URL을 직접 넣으면 새로고침 시 이미지가 깨지므로 + // 등록 직후 fetchQuestion으로 서버의 정식 URL을 받아온다. + await fetchQuestion(); } } catch (err) { console.error('댓글 등록 실패:', err); @@ -432,8 +437,12 @@ function QnADetailPage() { {comment.content} )} - {comment.imageUrl && ( - 댓글 첨부 이미지 + {comment.imageUrls?.length > 0 && ( +
+ {comment.imageUrls.map((url, idx) => ( + {`댓글 + ))} +
)}

{formatTime(comment.createdAt)}

@@ -516,9 +525,13 @@ function QnADetailPage() { )} - {/* ── 질문 첨부 이미지 ── */} - {question.imageUrl && ( - 첨부 이미지 + {/* ── 질문 첨부 이미지 (여러 장) ── */} + {question.imageUrls?.length > 0 && ( +
+ {question.imageUrls.map((url, idx) => ( + {`첨부 + ))} +
)} {/* ── 액션 버튼 (좋아요 / 댓글달기) ── */} @@ -548,13 +561,17 @@ function QnADetailPage() { {/* ── 하단 댓글 입력바 ── */}
- {imagePreview && ( -
- 미리보기 - + {imagePreviews.length > 0 && ( +
+ {imagePreviews.map((preview, idx) => ( +
+ {`미리보기 + +
+ ))}
)}
@@ -565,6 +582,7 @@ function QnADetailPage() { {isSubmitting ? '⏳' : } diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 211fcfd..a6f56a5 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -7,7 +7,7 @@ import { subscribeQuestionEvents } from '../../utils/sse'; import { CommentImoji, MeCuriousToo, SortBtn, OBtn, XBtn, CommentCommentArraw, SumitBtn, StaffCheck, ImgPreview, - DAY_PART_KO, DAY_OF_WEEK_KO, uploadImage, + DAY_PART_KO, DAY_OF_WEEK_KO, uploadImages, } from '../../utils/qnaUtils'; const MAX_VISIBLE_COMMENTS = 3; @@ -186,7 +186,9 @@ function QnAListPage() { // ── 댓글 입력 상태 ─────────────────────────────── const [commentOpenId, setCommentOpenId] = useState(null); const [commentInputs, setCommentInputs] = useState({}); + // 질문별 댓글 이미지 여러 장: { [questionId]: File[] } const [commentImages, setCommentImages] = useState({}); + // 질문별 댓글 이미지 미리보기: { [questionId]: string[] } const [commentImagePreviews, setCommentImagePreviews] = useState({}); const commentFileRefs = useRef({}); @@ -194,8 +196,9 @@ function QnAListPage() { const [newQuestion, setNewQuestion] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); - const [selectedImage, setSelectedImage] = useState(null); - const [imagePreview, setImagePreview] = useState(null); + // 질문 이미지 여러 장 + const [selectedImages, setSelectedImages] = useState([]); + const [imagePreviews, setImagePreviews] = useState([]); const fileInputRef = useRef(null); const applyQuestionGroups = useCallback((groups) => { @@ -243,20 +246,22 @@ function QnAListPage() { ...(questions.resolvedQuestions ?? []), ]; - // 질문 이미지 blob URL 변환 + // 질문 이미지 blob URL 변환 (여러 장: imageUrls 배열) const withBlob = await Promise.all( allQ.map(async (q) => { - let blobImageUrl = null; - if (q.imageUrl) { - try { - const imgRes = await authFetch(q.imageUrl); - const blob = await imgRes.blob(); - blobImageUrl = URL.createObjectURL(blob); - } catch { - blobImageUrl = null; - } - } - return { ...q, iLiked: q.isLiked, imageUrl: blobImageUrl }; + const rawUrls = q.imageUrls ?? []; + const blobUrls = await Promise.all( + rawUrls.map(async (url) => { + try { + const imgRes = await authFetch(url); + const blob = await imgRes.blob(); + return URL.createObjectURL(blob); + } catch { + return null; + } + }) + ); + return { ...q, iLiked: q.isLiked, imageUrls: blobUrls.filter(Boolean) }; }) ); @@ -288,21 +293,24 @@ function QnAListPage() { const buildQuestionFromCreatedEvent = useCallback(async (eventData) => { if (!eventData?.questionId) return null; - let blobImageUrl = null; - if (eventData.imageUrl) { - try { - const imgRes = await authFetch(eventData.imageUrl); - const blob = await imgRes.blob(); - blobImageUrl = URL.createObjectURL(blob); - } catch { - blobImageUrl = null; - } - } + // SSE 이벤트의 imageUrls 배열을 blob URL로 변환 + const rawUrls = eventData.imageUrls ?? []; + const blobUrls = await Promise.all( + rawUrls.map(async (url) => { + try { + const imgRes = await authFetch(url); + const blob = await imgRes.blob(); + return URL.createObjectURL(blob); + } catch { + return null; + } + }) + ); return { questionId: eventData.questionId, content: eventData.content, - imageUrl: blobImageUrl, + imageUrls: blobUrls.filter(Boolean), isResolved: false, isPopular: false, isLiked: false, @@ -506,15 +514,16 @@ function QnAListPage() { const handleCommentSubmit = async (e, questionId) => { e.stopPropagation(); const text = (commentInputs[questionId] || '').trim(); - if (!text && !commentImages[questionId]) return; + const images = commentImages[questionId] ?? []; + if (!text && images.length === 0) return; try { - let imageUrl = null; - if (commentImages[questionId]) { - imageUrl = await uploadImage(commentImages[questionId]); + let imageUrls = []; + if (images.length > 0) { + imageUrls = await uploadImages(images); } const res = await authFetch(`/api/questions/${questionId}/comments`, { method: 'POST', - body: JSON.stringify({ content: text, parentCommentId: null, imageUrl }), + body: JSON.stringify({ content: text, parentCommentId: null, imageUrls }), }); if (!res.ok) throw new Error(); const json = await res.json(); @@ -541,8 +550,8 @@ function QnAListPage() { setUnresolvedQuestions(update); setResolvedQuestions(update); setCommentInputs(prev => ({ ...prev, [questionId]: '' })); - setCommentImages(prev => ({ ...prev, [questionId]: null })); - setCommentImagePreviews(prev => ({ ...prev, [questionId]: null })); + setCommentImages(prev => ({ ...prev, [questionId]: [] })); + setCommentImagePreviews(prev => ({ ...prev, [questionId]: [] })); setCommentOpenId(null); } } catch (err) { @@ -552,10 +561,20 @@ function QnAListPage() { // ── 댓글 이미지 선택 / 붙여넣기 ───────────────── const handleCommentImageSelect = (e, questionId) => { - const file = e.target.files[0]; - if (!file) return; - setCommentImages(prev => ({ ...prev, [questionId]: file })); - setCommentImagePreviews(prev => ({ ...prev, [questionId]: URL.createObjectURL(file) })); + const files = Array.from(e.target.files); + if (!files.length) return; + const prev = commentImages[questionId] ?? []; + const merged = [...prev, ...files].slice(0, 5); + setCommentImages(p => ({ ...p, [questionId]: merged })); + setCommentImagePreviews(p => ({ ...p, [questionId]: merged.map(f => URL.createObjectURL(f)) })); + e.target.value = ''; + }; + + const handleCommentRemoveImage = (questionId, idx) => { + const prev = commentImages[questionId] ?? []; + const next = prev.filter((_, i) => i !== idx); + setCommentImages(p => ({ ...p, [questionId]: next })); + setCommentImagePreviews(p => ({ ...p, [questionId]: next.map(f => URL.createObjectURL(f)) })); }; const handleCommentPaste = (e, questionId) => { @@ -565,43 +584,53 @@ function QnAListPage() { if (item.type.startsWith('image/')) { const file = item.getAsFile(); if (file) { - setCommentImages(prev => ({ ...prev, [questionId]: file })); - setCommentImagePreviews(prev => ({ ...prev, [questionId]: URL.createObjectURL(file) })); + const prev = commentImages[questionId] ?? []; + const merged = [...prev, file].slice(0, 5); + setCommentImages(p => ({ ...p, [questionId]: merged })); + setCommentImagePreviews(p => ({ ...p, [questionId]: merged.map(f => URL.createObjectURL(f)) })); } break; } } }; - // ── 질문 이미지 선택 ───────────────────────────── + // ── 질문 이미지 선택 (여러 장) ─────────────────── const handleImageSelect = (e) => { - const file = e.target.files[0]; - if (!file) return; - setSelectedImage(file); - setImagePreview(URL.createObjectURL(file)); + const files = Array.from(e.target.files); + if (!files.length) return; + const merged = [...selectedImages, ...files].slice(0, 5); + setSelectedImages(merged); + setImagePreviews(merged.map(f => URL.createObjectURL(f))); + e.target.value = ''; + }; + + const handleRemoveImage = (idx) => { + const next = selectedImages.filter((_, i) => i !== idx); + setSelectedImages(next); + setImagePreviews(next.map(f => URL.createObjectURL(f))); }; // ── 새 질문 등록 ───────────────────────────────── const handleNewQuestion = async () => { const text = newQuestion.trim(); - if (!text && !selectedImage) return; + if (!text && selectedImages.length === 0) return; setIsSubmitting(true); setSubmitError(null); try { - let imageUrl = null; - if (selectedImage) { - imageUrl = await uploadImage(selectedImage); + let imageUrls = []; + if (selectedImages.length > 0) { + imageUrls = await uploadImages(selectedImages); } const res = await authFetch(`/api/sessions/${sessionId}/questions`, { method: 'POST', - body: JSON.stringify({ content: text, imageUrl }), + body: JSON.stringify({ content: text, imageUrls }), }); if (!res.ok) throw new Error(); const json = await res.json(); if (json.isSuccess) { setNewQuestion(''); - setSelectedImage(null); - setImagePreview(null); + setSelectedImages([]); + setImagePreviews([]); fetchQuestions(understandingIndex); } } catch (err) { @@ -768,11 +797,14 @@ function QnAListPage() {
- {/* 질문 첨부 이미지 */} - {question.imageUrl && ( - 첨부 이미지 e.stopPropagation()} /> + {/* 질문 첨부 이미지 (여러 장) */} + {question.imageUrls?.length > 0 && ( +
e.stopPropagation()}> + {question.imageUrls.map((url, idx) => ( + {`첨부 + ))} +
)} {/* 댓글 미리보기 */} @@ -815,17 +847,20 @@ function QnAListPage() { {/* 댓글 입력창 */} {commentOpenId === question.questionId && (
e.stopPropagation()}> - {commentImagePreviews[question.questionId] && ( -
- 미리보기 - + {(commentImagePreviews[question.questionId] ?? []).length > 0 && ( +
+ {(commentImagePreviews[question.questionId] ?? []).map((preview, idx) => ( +
+ {`미리보기 + +
+ ))}
)}
@@ -837,6 +872,7 @@ function QnAListPage() { commentFileRefs.current[question.questionId] = document.createElement('input'); commentFileRefs.current[question.questionId].type = 'file'; commentFileRefs.current[question.questionId].accept = 'image/*'; + commentFileRefs.current[question.questionId].multiple = true; commentFileRefs.current[question.questionId].onchange = (ev) => handleCommentImageSelect(ev, question.questionId); } commentFileRefs.current[question.questionId].click(); @@ -868,13 +904,17 @@ function QnAListPage() { {!isPast && (
{submitError &&

{submitError}

} - {imagePreview && ( -
- 미리보기 - + {imagePreviews.length > 0 && ( +
+ {imagePreviews.map((preview, idx) => ( +
+ {`미리보기 + +
+ ))}
)}
@@ -888,6 +928,7 @@ function QnAListPage() { dayPart === 'AM' ? '10:00 ~ 13:00' : '14:00 // 운영진 여부 판단 (displayName이 '운영진'으로 시작하면 true) export const isStaffDisplay = (displayName) => displayName?.startsWith('운영진') ?? false; -// 이미지 업로드 후 서버 URL 반환 +// 이미지 단일 업로드 후 서버 URL 반환 export const uploadImage = async (file) => { const formData = new FormData(); formData.append('file', file); @@ -108,4 +108,20 @@ export const uploadImage = async (file) => { }); const json = await res.json(); return json.imageUrl; +}; + +// 이미지 여러 장 업로드 후 서버 URL 목록 반환 +// files: File[] (최대 5장) +export const uploadImages = async (files) => { + if (!files || files.length === 0) return []; + const formData = new FormData(); + files.forEach(file => formData.append('files', file)); + const token = localStorage.getItem('token'); + const res = await fetch('/api/images/multi', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData, + }); + const json = await res.json(); + return json.imageUrls ?? []; }; \ No newline at end of file