From 0784a06e636793eef01a6303cd2d5a9fc48166c5 Mon Sep 17 00:00:00 2001 From: kdhye1119 Date: Thu, 18 Jun 2026 13:26:19 +0900 Subject: [PATCH] =?UTF-8?q?[bug]=20=EC=82=AC=EC=A7=84=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnADetailPage.js | 125 +++++++++++- .../src/pages/qna/QnADetailPage.module.css | 192 +++++++++++++++++- frontend/src/pages/qna/QnAListPage.js | 20 +- frontend/src/utils/Api.js | 32 ++- 4 files changed, 358 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index 52d2e1e..bb93229 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -2,7 +2,7 @@ import '../../assets/styles/global.css'; import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import styles from './QnADetailPage.module.css'; -import { FiMoreVertical, FiCornerDownRight, FiChevronLeft } from 'react-icons/fi'; +import { FiMoreVertical, FiCornerDownRight, FiChevronLeft, FiChevronRight, FiX } from 'react-icons/fi'; import { CommentImoji, MeCuriousToo, @@ -97,6 +97,60 @@ function QnADetailPage() { const [editingCommentId, setEditingCommentId] = useState(null); const [editCommentText, setEditCommentText] = useState(''); + // ── 이미지 확대보기(라이트박스) 상태 ───────────── + // images: 같은 묶음(질문 또는 한 댓글)의 이미지 url 배열, index: 현재 보고 있는 인덱스 + const [lightbox, setLightbox] = useState(null); + + const openLightbox = (images, index) => setLightbox({ images, index }); + const closeLightbox = () => setLightbox(null); + + const showPrevImage = useCallback(() => { + setLightbox(prev => { + if (!prev) return prev; + const nextIndex = (prev.index - 1 + prev.images.length) % prev.images.length; + return { ...prev, index: nextIndex }; + }); + }, []); + + const showNextImage = useCallback(() => { + setLightbox(prev => { + if (!prev) return prev; + const nextIndex = (prev.index + 1) % prev.images.length; + return { ...prev, index: nextIndex }; + }); + }, []); + + useEffect(() => { + if (!lightbox) return undefined; + + const handleKeyDown = (e) => { + if (e.key === 'Escape') closeLightbox(); + if (e.key === 'ArrowLeft') showPrevImage(); + if (e.key === 'ArrowRight') showNextImage(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [lightbox, showPrevImage, showNextImage]); + + // 모바일 좌우 스와이프로 이미지 넘기기 + const touchStartXRef = useRef(null); + + const handleLightboxTouchStart = (e) => { + touchStartXRef.current = e.touches[0].clientX; + }; + + const handleLightboxTouchEnd = (e) => { + if (touchStartXRef.current === null) return; + const deltaX = e.changedTouches[0].clientX - touchStartXRef.current; + const SWIPE_THRESHOLD = 50; + if (deltaX > SWIPE_THRESHOLD) { + showPrevImage(); + } else if (deltaX < -SWIPE_THRESHOLD) { + showNextImage(); + } + touchStartXRef.current = null; + }; + const fetchQuestion = useCallback(async ({ showLoading = false } = {}) => { try { @@ -446,7 +500,13 @@ function QnADetailPage() { {comment.imageUrls?.length > 0 && (
{comment.imageUrls.map((url, idx) => ( - {`댓글 + {`댓글 openLightbox(comment.imageUrls, idx)} + /> ))}
)} @@ -544,7 +604,13 @@ function QnADetailPage() { {question.imageUrls?.length > 0 && (
{question.imageUrls.map((url, idx) => ( - {`첨부 + {`첨부 openLightbox(question.imageUrls, idx)} + /> ))}
)} @@ -622,8 +688,59 @@ function QnADetailPage() { + + {/* ── 이미지 확대보기 ── */} + {lightbox && ( +
+ + + {lightbox.images.length > 1 && ( + + )} + + {`확대 e.stopPropagation()} + /> + + {lightbox.images.length > 1 && ( + + )} + + {lightbox.images.length > 1 && ( +
+ {lightbox.index + 1} / {lightbox.images.length} +
+ )} +
+ )} ); } -export default QnADetailPage; +export default QnADetailPage; \ No newline at end of file diff --git a/frontend/src/pages/qna/QnADetailPage.module.css b/frontend/src/pages/qna/QnADetailPage.module.css index c1e4091..83616c3 100644 --- a/frontend/src/pages/qna/QnADetailPage.module.css +++ b/frontend/src/pages/qna/QnADetailPage.module.css @@ -244,12 +244,29 @@ } /* ── 질문 첨부 이미지 ── */ +.questionImages { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 18px; +} + .questionImage { width: 100%; border-radius: 12px; object-fit: cover; display: block; - margin-bottom: 18px; + cursor: zoom-in; + transition: opacity 0.15s, transform 0.15s; +} + +.questionImage:hover { + opacity: 0.92; +} + +/* 이미지가 여러 장일 때는 한 줄에 나눠 배치 (간격은 위 gap 속성으로 처리) */ +.questionImages:has(.questionImage:nth-child(2)) .questionImage { + width: calc(50% - 5px); } /* ── 액션 버튼 행 (좋아요 / 댓글달기) ── */ @@ -419,12 +436,30 @@ margin-top: 2px; } +.commentImages { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .commentImage { width: 100%; max-width: 380px; border-radius: 8px; object-fit: cover; display: block; + cursor: zoom-in; + transition: opacity 0.15s; +} + +.commentImage:hover { + opacity: 0.92; +} + +/* 댓글에 이미지가 여러 장일 때는 한 줄에 나눠 배치 */ +.commentImages:has(.commentImage:nth-child(2)) .commentImage { + width: calc(50% - 4px); + max-width: calc(50% - 4px); } .commentDate { @@ -550,6 +585,12 @@ } /* ── 이미지 미리보기 ── */ +.imagePreviewList { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + .imagePreviewWrapper { position: relative; display: inline-block; @@ -558,6 +599,14 @@ align-self: flex-start; } +.imagePreviewList .imagePreviewWrapper { + margin: 4px 0 0 0; +} + +.imagePreviewList .imagePreviewWrapper:first-child { + margin-left: 12px; +} + .imagePreview { width: 80px; height: 80px; @@ -583,6 +632,109 @@ z-index: 101; } +/* ── 이미지 확대보기(라이트박스) ── */ +.lightboxOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 40px; + box-sizing: border-box; + cursor: zoom-out; + animation: lightboxFadeIn 0.15s ease-out; +} + +@keyframes lightboxFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.lightboxImage { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 8px; + cursor: default; + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.4); +} + +.lightboxCloseBtn { + position: fixed; + top: 24px; + right: 24px; + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + border: none; + color: var(--white); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1001; + transition: background 0.15s; +} + +.lightboxCloseBtn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.lightboxPrevBtn, +.lightboxNextBtn { + position: fixed; + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 48px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.15); + border: none; + color: var(--white); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 1001; + transition: background 0.15s; +} + +.lightboxPrevBtn:hover, +.lightboxNextBtn:hover { + background: rgba(255, 255, 255, 0.3); +} + +.lightboxPrevBtn { + left: 24px; +} + +.lightboxNextBtn { + right: 24px; +} + +.lightboxCounter { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.15); + color: var(--white); + font-family: var(--font-main); + font-size: 13px; + font-weight: 500; + padding: 5px 14px; + border-radius: 20px; + z-index: 1001; +} + /* ════════════════════════════════════════ 반응형 — 태블릿 (768px 이하) ════════════════════════════════════════ */ @@ -682,8 +834,9 @@ font-size: 13px; } - .commentImage { - max-width: 100%; + .commentImages:has(.commentImage:nth-child(2)) .commentImage { + width: calc(50% - 4px); + max-width: calc(50% - 4px); } /* ── 댓글 수정 ── */ @@ -701,4 +854,35 @@ bottom: 12px; padding: 6px 10px; } -} + + /* ── 라이트박스 ── */ + .lightboxOverlay { + padding: 16px; + } + + .lightboxCloseBtn { + top: 12px; + right: 12px; + width: 38px; + height: 38px; + } + + .lightboxPrevBtn, + .lightboxNextBtn { + width: 38px; + height: 38px; + } + + .lightboxPrevBtn { + left: 8px; + } + + .lightboxNextBtn { + right: 8px; + } + + .lightboxCounter { + bottom: 16px; + font-size: 12px; + } +} \ No newline at end of file diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 3f56a65..7f0e696 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -610,6 +610,23 @@ function QnAListPage() { setImagePreviews(next.map(f => URL.createObjectURL(f))); }; + const handleNewQuestionPaste = (e) => { + if (isStaff) return; // 이해도 체크 입력창에는 이미지 첨부 없음 + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + const merged = [...selectedImages, file].slice(0, 5); + setSelectedImages(merged); + setImagePreviews(merged.map(f => URL.createObjectURL(f))); + } + break; + } + } + }; + // ── 새 질문 등록 ───────────────────────────────── const handleNewQuestion = async () => { const text = newQuestion.trim(); @@ -945,6 +962,7 @@ function QnAListPage() { onKeyDown={e => { if (e.key === 'Enter') isStaff ? handleNewUnderstandCheck() : handleNewQuestion(); }} + onPaste={handleNewQuestionPaste} disabled={isSubmitting} />