From 2f3089de4ea1dd8263baebac6196e63ca5014c66 Mon Sep 17 00:00:00 2001 From: kdhye1119 Date: Thu, 14 May 2026 11:39:00 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[feat]qna=20list=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=BC=EB=B6=80=20=EA=B5=AC=ED=98=84=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnAListPage.js | 534 +++++++++++++++++ frontend/src/pages/qna/QnAListPage.module.css | 555 ++++++++++-------- 2 files changed, 853 insertions(+), 236 deletions(-) diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index e69de29..bf8e4d7 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -0,0 +1,534 @@ +import { useState } from 'react'; +import styles from './QnAListPage.module.css'; +import { FiChevronLeft, FiChevronRight, FiMessageSquare } from 'react-icons/fi'; + + +const UNDERSTAND = ['이해했다', '성공했다']; + +// 댓글 최대 표시 개수 (카드에서 항상 노출) +const MAX_VISIBLE_COMMENTS = 3; + +// 질문 목록 데이터 +const MOCK_QUESTIONS = [ + { + id: 1, + text: '벤브 어떻게 활성화 시켜요?', + likes: 7, + iLiked: false, // 내가 좋아요 눌렀는지 + image: null, // 첨부 이미지 없음 + comments: [], // 댓글 없음 + }, + { + id: 2, + text: '오류났어요', + likes: 7, + iLiked: false, + image: 'https://dora-guide.com/wp-content/uploads/2019/11/Visual-studio-code-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%82%AC%EC%9A%A9%EB%B2%95.png', + comments: [ + { id: 1, author: '운영진1', isStaff: true, content: '사진 참고하세요' }, + { id: 2, author: '작성자', isStaff: false, content: '감사합니다' }, + { id: 3, author: '익명1', isStaff: false, content: '감사합니다' }, + ], + }, + { + id: 3, + text: '벤브 어떻게 활성화 시켜요?', + likes: 7, + iLiked: false, + image: null, + comments: [], + }, + { + id: 4, + text: '벤브 어떻게 활성화 시켜요?', + likes: 7, + iLiked: false, + image: null, + comments: [], + }, + { + id: 5, + text: '벤브 어떻게 활성화 시켜요?', + likes: 7, + iLiked: false, + image: null, + comments: [], + }, +]; + + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// 메인 컴포넌트 +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +// props 설명: +// sessionTitle → 상단에 보여줄 세션 제목 (QnAMainPage에서 넘겨줄 거야) +// onBack → 뒤로가기 버튼 눌렀을 때 실행할 함수 (선택) +// onCardClick → 카드 클릭 시 상세 페이지로 이동할 함수 (questionId를 인자로 받아) +// +// 사용 예시 (QnAMainPage에서): +// navigate(-1)} +// onCardClick={(id) => navigate(`/qna/${sessionId}/question/${id}`)} +// /> +// +// React Router 쓴다면 QnAMainPage 카드 onClick에 이렇게: +// onClick={() => navigate('/qna/1', { state: { title: session.title } })} +// 그리고 이 컴포넌트에서: const { state } = useLocation(); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// API 설정 +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + +function QnAListPage({ + sessionTitle = '1주차 화요일 오전 세션(HTML/CSS)', + sessionId = 1, + onBack, + onCardClick, // 카드 클릭 시 상세 페이지 이동 (questionId를 인자로 받아) +}) { + + // ────────────────────────────────────── + // 📌 useState 사용법: + // const [현재값, 값을바꾸는함수] = useState(초기값); + // 값을바꾸는함수(새값) 호출하면 화면이 자동으로 다시 그려져 + // ────────────────────────────────────── + + // 현재 보고 있는 이해도 체크 인덱스 (0 = '이해했다') + const [understandIndex, setUnderstandIndex] = useState(0); + + // 저도 궁금해요 필터 켜져 있는지 + const [filterCurious, setFilterCurious] = useState(false); + + // 정렬 방식 + const [sortOrder, setSortOrder] = useState('최신순'); + + // 정렬 드롭다운 열려 있는지 + const [showSortMenu, setShowSortMenu] = useState(false); + + // 질문 목록 (좋아요 토글 등 변경사항 반영하려고 state로 관리) + const [questions, setQuestions] = useState(MOCK_QUESTIONS); + + // 내가 이해했는지 여부: true = O 누름, false = X 누름, null = 아직 안 누름 + const [myUnderstand, setMyUnderstand] = useState(null); + + // 댓글 입력창이 열려 있는 질문 id + // null이면 아무 질문도 댓글 입력창이 안 열려 있는 상태 + // '댓글달기' 버튼을 누른 질문의 id가 들어옴 + const [commentOpenId, setCommentOpenId] = useState(null); + + // 각 질문별 댓글 입력창 텍스트 (객체로 관리: { 질문id: 입력된텍스트 }) + const [commentInputs, setCommentInputs] = useState({}); + + // 새 질문 입력창 텍스트 + const [newQuestion, setNewQuestion] = useState(''); + + // API 요청 중인지 여부 (true면 버튼 비활성화 → 중복 제출 방지) + const [isSubmitting, setIsSubmitting] = useState(false); + + // 에러 메시지 (null이면 에러 없음) + const [submitError, setSubmitError] = useState(null); + + + // ────────────────────────────────────── + // 이벤트 핸들러 함수들 + // ────────────────────────────────────── + + // < 버튼: 이전 이해도 체크로 이동 + const goPrevUnderstand = () => { + if (understandIndex > 0) { + setUnderstandIndex(prev => prev - 1); + } + }; + // > 버튼: 다음 이해도 체크로 이동 + const goNextUnderstand = () => { + setUnderstandIndex(prev => (prev + 1) % UNDERSTAND.length); + }; + + // 좋아요 버튼 토글 + const toggleLike = (e, id) => { + e.stopPropagation(); // 카드 클릭(상세 이동) 방지 + + setQuestions(prev => + // prev = 이전 질문 목록 배열 + // map으로 전체 돌면서 해당 id의 질문만 변경 + prev.map(q => + q.id === id + ? { + ...q, // 나머지 필드는 그대로 복사 (spread 연산자) + iLiked: !q.iLiked, + likes: q.iLiked ? q.likes - 1 : q.likes + 1, + } + : q // 다른 질문은 그대로 + ) + ); + }; + + // '댓글달기' 버튼 클릭: 해당 질문의 댓글 입력창 열기/닫기 토글 + // 이미 열려 있는 질문이면 닫고, 아니면 해당 id로 열기 + const toggleCommentInput = (e, questionId) => { + e.stopPropagation(); // 카드 클릭(상세 이동) 방지 + setCommentOpenId(prev => prev === questionId ? null : questionId); + }; + + // 댓글 입력창 텍스트 변경 + const handleCommentChange = (questionId, value) => { + setCommentInputs(prev => ({ + ...prev, // 기존 다른 질문의 입력값은 유지 + [questionId]: value, // 이 질문의 입력값만 업데이트 + })); + }; + + // 댓글 제출 (엔터 or 버튼 클릭) + const handleCommentSubmit = (e, questionId) => { + e.stopPropagation(); // 카드 클릭(상세 이동) 방지 + + const text = (commentInputs[questionId] || '').trim(); + if (!text) return; // 빈 댓글은 무시 + + setQuestions(prev => + prev.map(q => + q.id === questionId + ? { + ...q, + comments: [ + ...q.comments, + // Date.now()로 임시 고유 id 생성 + { id: Date.now(), author: '나', isStaff: false, content: text }, + ], + } + : q + ) + ); + + // 입력창 비우기 + 댓글 입력창 닫기 + setCommentInputs(prev => ({ ...prev, [questionId]: '' })); + setCommentOpenId(null); + }; + + // ✅ 새 질문 등록 함수 + // async: 비동기 함수 선언 키워드 (await를 쓰려면 필요해) + // 나중에 실제 API 연결할 때 await axios.post(...) 식으로 사용하게 됨 + const handleNewQuestion = async () => { + const text = newQuestion.trim(); // 앞뒤 공백 제거 + if (!text) return; // 빈 질문이면 무시 + + setIsSubmitting(true); // 버튼 비활성화 (중복 제출 방지) + setSubmitError(null); // 이전 에러 메시지 초기화 + + try { + // TODO: 실제 API 연결 시 아래 주석 풀기 + // await axios.post(`/api/sessions/${sessionId}/questions`, { content: text }); + + // 지금은 API 없이 바로 화면에 추가 (임시) + // ...prev = 기존 질문 목록 유지, 새 질문을 맨 앞에 추가 + setQuestions(prev => [ + { id: Date.now(), text, likes: 0, iLiked: false, image: null, comments: [] }, + ...prev, + ]); + setNewQuestion(''); // 입력창 비우기 + + } catch (error) { + // 네트워크 오류 or 서버 오류 처리 + console.error('질문 등록 실패:', error); + setSubmitError('질문 등록에 실패했어요.'); + } finally { + setIsSubmitting(false); // 버튼 다시 활성화 + } + }; + + + // ────────────────────────────────────── + // 렌더링에 쓸 계산값들 + // ────────────────────────────────────── + + // 현재 이해도 이름 + const currentUnderstand = UNDERSTAND[understandIndex]; + + // 저도 궁금해요 필터가 켜져 있으면 필터링 + const displayedQuestions = filterCurious + ? questions.filter(q => q.iCurious) + : questions; + + + + return ( + // 전체 페이지 컨테이너 + // 하단 새 질문 입력창 높이만큼 padding-bottom 줘야 내용이 가려지지 않아 +
+ + {/* ── 세션 제목 ── */} +

{sessionTitle}

+ + + {/* ── 필터/정렬 행 ── */} +
+ + {/* 저도 궁금해요 체크박스 */} + {/* label 안에 input 넣으면 label 클릭해도 체크박스가 토글돼 */} + + + {/* 정렬 드롭다운 */} + {/* position: relative인 wrapper로 감싸야 드롭다운 위치가 버튼 아래에 붙어 */} +
+ + + {/* showSortMenu가 true일 때만 메뉴를 렌더링 */} + {/* JSX에서 조건부 렌더링: {조건 && <보여줄JSX />} */} + {showSortMenu && ( +
    + {['최신순', '좋아요순'].map(option => ( +
  • { + setSortOrder(option); + setShowSortMenu(false); // 선택하면 메뉴 닫기 + }} + > + {option} +
  • + ))} +
+ )} +
+
+ + + {/* ── 이해도 체크 바 ── */} +
+ + {/* 이전 이해도 체크 버튼 */} + + + {/* 이해도 체크 이름 + 카운트 */} + + {currentUnderstand} + (13/29) + {/* TODO: 실제 카운트는 API에서 받아와야 해 */} + + + {/* O 버튼: 이해했어요 */} + {/* 눌린 상태면 oxActive 클래스 추가해서 스타일 변경 */} + {/* JSX에서 여러 클래스 합치기: `${styles.a} ${조건 ? styles.b : ''}` */} + + + {/* X 버튼: 모르겠어요 */} + + + {/* 다음 이해도 체크 버튼 */} + +
+ + + {/* ── 질문 목록 ── */} +
+ + {/* displayedQuestions 배열을 map으로 돌면서 카드 하나씩 렌더링 */} + {displayedQuestions.map(question => ( +
onCardClick?.(question.id)} + > + + {/* 질문 헤더 */} +
+ + {/* Q. 아이콘 */} + Q. + + {/* 질문 텍스트 */} + {question.text} + + {/* 오른쪽 액션 버튼들 */} +
+ + {/* 좋아요 버튼 */} + + + {/* 댓글달기 버튼: 클릭하면 해당 질문의 댓글 입력창 열기/닫기 */} + +
+
+ + {/* ── 댓글 미리보기 (항상 노출, 최대 MAX_VISIBLE_COMMENTS개) ── + 댓글이 있을 때만 렌더링 + slice(0, MAX_VISIBLE_COMMENTS)로 앞 3개만 잘라서 표시 */} + {question.comments.length > 0 && ( +
+ {question.comments.slice(0, MAX_VISIBLE_COMMENTS).map(comment => ( +
+ {/* 작성자 이름 (운영진이면 파란색으로 강조) */} + + {comment.author} + {comment.isStaff && ( + 🔵 + )} + + {/* 댓글 내용 */} +
+ ↳ {comment.content} +
+
+ ))} + + {/* 댓글이 3개를 초과하면 "외 N개" 텍스트로 나머지 개수만 안내 */} + {/* 상세 페이지에서 전체 댓글을 볼 수 있으므로 더보기 버튼 없이 텍스트만 표시 */} + {question.comments.length > MAX_VISIBLE_COMMENTS && ( + + 외 {question.comments.length - MAX_VISIBLE_COMMENTS}개 댓글 + + )} +
+ )} + + {/* ── 댓글 입력창 ── + commentOpenId === question.id 일 때만 렌더링 + (조건이 false면 아무것도 그리지 않아) */} + {commentOpenId === question.id && ( +
e.stopPropagation()} // 카드 클릭(상세 이동) 방지 + > + handleCommentChange(question.id, e.target.value)} + // 엔터 누르면 제출 + onKeyDown={e => { + if (e.key === 'Enter') handleCommentSubmit(e, question.id); + }} + autoFocus // 입력창 열리면 자동으로 포커스 + /> + +
+ )} +
+ ))} +
+ + + {/* ── 하단 고정: 새 질문 입력창 ── + position: fixed로 화면 하단에 고정돼 있어 + 스크롤해도 항상 보여 */} +
+ + {/* 에러 메시지: submitError가 있을 때만 보여줌 */} + {submitError && ( +

{submitError}

+ )} + +
+ + + setNewQuestion(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') handleNewQuestion(); + }} + // 요청 중엔 입력 막기 + disabled={isSubmitting} + /> + +
+
+ +
+ ); +} + +export default QnAListPage; + + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// QnAMainPage에서 이 페이지로 이동하는 방법 +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━═══ +// +// ① React Router 설치: +// npm install react-router-dom +// +// ② App.jsx (또는 라우터 설정 파일) 에서: +// import { BrowserRouter, Routes, Route } from 'react-router-dom'; +// import QNAMainPage from './QNAMainPage'; +// import QnADetailPage from './QnADetailPage'; +// +// +// +// } /> +// } /> +// +// +// +// ③ QnAMainPage.jsx 카드/리스트 클릭 시: +// import { useNavigate } from 'react-router-dom'; +// const navigate = useNavigate(); +// +// // 카드 onClick: +// onClick={() => navigate(`/qna/${session.id}`, { state: { title: `${session.week} 세션(${session.title})` } })} +// +// ④ 이 컴포넌트에서 제목 받아오려면: +// import { useLocation } from 'react-router-dom'; +// const { state } = useLocation(); +// // sessionTitle prop 대신: state?.title \ No newline at end of file diff --git a/frontend/src/pages/qna/QnAListPage.module.css b/frontend/src/pages/qna/QnAListPage.module.css index 7f81336..c6b04f5 100644 --- a/frontend/src/pages/qna/QnAListPage.module.css +++ b/frontend/src/pages/qna/QnAListPage.module.css @@ -1,441 +1,524 @@ -/* ── Page ───────────────────────────────────────────────────────── */ +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + QnAListPage.module.css + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + QnAMainPage와 같은 CSS 변수(--font-main, --black 등)를 사용하니까 + 전역 CSS에 변수가 정의돼 있어야 해 +*/ + + +/* ── 전체 페이지 컨테이너 ── */ .page { + min-height: 100vh; max-width: 880px; margin: 0 auto; - min-height: 100vh; - padding-bottom: 6rem; + /* 하단 고정 입력창 높이만큼 아래 패딩 줘야 내용이 가려지지 않아 */ + padding: 2rem 1rem 5rem; + position: relative; } -.sessionHeader { - text-align: center; - padding: 10px; -} - -.sessionTitle { +/* ── 세션 제목 ── */ +.title { font-family: var(--font-main); - font-size: 2rem; + font-size: 1.6rem; font-weight: 700; color: var(--black); - margin-bottom: 30px; - margin-top: 50px; + text-align: center; + margin-bottom: 1.5rem; } -.sessionControls { + +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 필터 / 정렬 행 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.filterRow { display: flex; - justify-content: flex-end; align-items: center; - gap: 0.75rem; + justify-content: space-between; + /* 왼쪽 체크박스 / 오른쪽 정렬버튼 */ + margin-bottom: 0.75rem; } -.like { +/* 저도 궁금해요 체크박스 + 텍스트 묶음 */ +.curiousLabel { display: flex; align-items: center; - gap: 0.375rem; + gap: 0.4rem; font-family: var(--font-main); - font-size: 0.875rem; - color: var(--gray600); + font-size: 0.9rem; + color: var(--black); cursor: pointer; + user-select: none; + /* 레이블 드래그 선택 방지 */ } -.like input[type='checkbox'] { - accent-color: var(--light); - width: 15px; - height: 15px; +.curiousCheckbox { + width: 0.95rem; + height: 0.95rem; + cursor: pointer; + accent-color: var(--dark, #3cb371); + /* 체크박스 색상 */ } -.sortSelect { - border: 1px solid var(--gray200); - border-radius: 20px; - padding: 0.25rem 0.75rem; +/* 정렬 드롭다운 wrapper - position:relative로 자식의 absolute 기준점이 됨 */ +.sortWrapper { + position: relative; +} + +.sortBtn { + background: none; + border: 1px solid var(--gray200, #e0e0e0); + border-radius: 6px; + padding: 0.3rem 0.7rem; font-family: var(--font-main); font-size: 0.875rem; - color: var(--gray600); + cursor: pointer; + color: var(--black); + transition: background 0.15s; +} + +.sortBtn:hover { + background: var(--gray200, #f5f5f5); +} + +/* 드롭다운 메뉴 - sortBtn 아래에 떠있어 */ +.sortMenu { + position: absolute; + right: 0; + top: calc(100% + 4px); + /* 버튼 바로 아래 */ background: var(--white); + border: 1px solid var(--gray200, #e0e0e0); + border-radius: 8px; + box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.12); + list-style: none; + padding: 0.3rem 0; + margin: 0; + z-index: 100; + /* 다른 요소 위로 올라오게 */ + min-width: 90px; +} + +.sortOption { + padding: 0.5rem 1rem; + font-family: var(--font-main); + font-size: 0.875rem; cursor: pointer; - outline: none; + color: var(--black); + transition: background 0.1s; +} + +.sortOption:hover { + background: var(--gray200, #f5f5f5); } -/* ── 이해도 체크 네비게이터 ──────────────────────────────────────── */ -.understandingRow { + +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 이해도 체크 바 (이해했다 < >) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.understandBar { display: flex; align-items: center; - justify-content: space-between; - background: var(--white); - border-radius: 0.625rem; - box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 0.25); - padding: 0.75rem 1rem; - margin-bottom: 1rem; gap: 0.5rem; + background: var(--white); + border-radius: 10px; + padding: 0.7rem 1rem; + box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 0.12); + margin-bottom: 1.25rem; } -.navArrow { +/* < > 화살표 버튼 */ +.arrowBtn { background: none; border: none; - font-size: 1.5rem; - color: var(--gray600); cursor: pointer; - padding: 0 0.25rem; - line-height: 1; - transition: color 0.15s; - flex-shrink: 0; -} - -.navArrow:hover:not(:disabled) { color: var(--black); + display: flex; + align-items: center; + padding: 0.2rem; + border-radius: 4px; + transition: background 0.15s; + flex-shrink: 0; } -.navArrow:disabled { - opacity: 0.25; - cursor: default; +.arrowBtn:hover { + background: var(--gray200, #f0f0f0); } -.understandingLabel { - display: flex; - align-items: center; - gap: 0.5rem; +/* 이해도 체크 이름 */ +.understandName { flex: 1; -} - -.understandingText { + /* 남은 공간 차지 */ font-family: var(--font-main); - font-size: 1.1rem; + font-size: 1rem; font-weight: 600; color: var(--black); + text-align: center; } -.understandingCount { - font-family: var(--font-main); - font-size: 1rem; +/* 이해도 카운트 (13/29) */ +.understandCount { font-weight: 400; - color: var(--gray600); -} - -.checkButtons { - display: flex; - gap: 0.5rem; - flex-shrink: 0; + font-size: 0.875rem; + color: var(--gray600, #757575); } -.checkBtn { - width: 2.25rem; - height: 2.25rem; +/* O / X 공통 스타일 */ +.oxBtn { + background: none; + border: 1.5px solid var(--gray200, #e0e0e0); border-radius: 50%; - background: var(--white); - font-family: var(--font-main); - font-size: 0.9375rem; + width: 2rem; + height: 2rem; + font-size: 0.875rem; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: all 0.15s ease; - border: 1.5px solid var(--gray200); + transition: all 0.15s; + flex-shrink: 0; } -.checkO { - border-color: #4caf50; - color: #4caf50; +/* O 버튼 (초록) */ +.oxO { + color: #3cb371; } -.checkO:hover { - background: #f0faf0; +.oxO:hover { + border-color: #3cb371; + background: rgba(60, 179, 113, 0.08); } -.checkActive { - background: #4caf50; - color: var(--white); +/* X 버튼 (회색/빨강) */ +.oxX { + color: var(--gray600, #9e9e9e); } -.checkX { - border-color: #e53935; - color: #e53935; +.oxX:hover { + border-color: #e57373; + color: #e57373; + background: rgba(229, 115, 115, 0.08); } -.checkX:hover { - background: #fff5f5; +/* 눌린 상태 */ +.oxActive { + border-color: currentColor; + background: rgba(0, 0, 0, 0.06); } -.checkActiveX { - background: #e53935; - color: var(--white); -} -/* ── Question List ──────────────────────────────────────────────── */ +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 질문 목록 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + .questionList { display: flex; flex-direction: column; gap: 0.75rem; } -/* ── Question Card ──────────────────────────────────────────────── */ -.card { +/* 질문 카드 하나 */ +.questionCard { background: var(--white); - border-radius: 0.625rem; - padding: 1rem; - box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 0.25); + border-radius: 10px; + box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 0.15); + overflow: hidden; + /* 내부 요소가 border-radius 밖으로 안 튀어나오게 */ + cursor: pointer; transition: box-shadow 0.2s; } -.card:hover { - box-shadow: 4px 4px 10px 0 rgba(0, 0, 0, 0.2); +.questionCard:hover { + box-shadow: 4px 4px 10px 0 rgba(0, 0, 0, 0.18); } +/* 질문 헤더 (항상 보이는 부분) */ .questionHeader { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 0.5rem; -} - -.questionLeft { - display: flex; - align-items: flex-start; - gap: 0.5rem; - flex: 1; + align-items: center; + gap: 0.6rem; + padding: 0.85rem 1rem; } +/* Q. 아이콘 */ .qIcon { font-family: var(--font-main); - font-size: 1rem; - font-weight: 800; - color: #ff9800; + font-size: 1.05rem; + font-weight: 700; + color: #5cba5c; flex-shrink: 0; - line-height: 1.5; } +/* 질문 텍스트 */ .questionText { + flex: 1; + /* 남은 공간 차지 */ font-family: var(--font-main); font-size: 1rem; - font-weight: 500; color: var(--black); - line-height: 1.5; + line-height: 1.4; } +/* 오른쪽 버튼 묶음 */ .questionActions { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.75rem; flex-shrink: 0; + /* 텍스트가 길어도 버튼은 줄어들지 않음 */ } -.solvedBadge { - background: #e8f5e9; - color: #388e3c; - font-family: var(--font-main); - font-size: 0.75rem; +/* 좋아요 버튼 */ +.likeBtn { + background: none; + border: none; + font-size: 0.875rem; + cursor: pointer; + color: var(--gray600, #757575); + display: flex; + align-items: center; + gap: 0.2rem; + transition: color 0.15s; + padding: 0; +} + +/* 좋아요 누른 상태 */ +.likeBtn.liked { + color: #2e7d32; font-weight: 600; - padding: 2px 0.5rem; - border-radius: 10px; - border: 1px solid #c8e6c9; } -.likeBtn, +/* 댓글달기 버튼 */ .commentBtn { background: none; border: none; - font-family: var(--font-main); - font-size: 0.8125rem; - color: var(--gray600); + font-size: 0.8rem; cursor: pointer; - padding: 0.25rem 0.375rem; - border-radius: 6px; - transition: all 0.15s ease; + color: var(--gray600, #757575); + display: flex; + align-items: center; + gap: 0.2rem; white-space: nowrap; + /* 줄바꿈 방지 */ + transition: color 0.15s; + padding: 0; } -.likeBtn:hover, -.commentBtn:hover { - background: #f5f5f5; +.commentBtn:hover, +.likeBtn:hover { color: var(--black); } -.liked { - color: #ff9800; -} -.questionMeta { - display: block; - font-family: var(--font-main); - font-size: 0.75rem; - color: var(--gray600); - margin-top: 0.375rem; - padding-left: 1.5rem; -} +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 댓글 미리보기 (카드에서 항상 노출, 최대 3개) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ -/* ── Comments ───────────────────────────────────────────────────── */ -.commentSection { - margin-top: 0.875rem; - border-top: 1px solid var(--gray200); - padding-top: 0.75rem; +.commentPreview { + border-top: 1px solid var(--gray200, #eeeeee); + padding: 0.6rem 1rem 0.75rem; display: flex; flex-direction: column; - gap: 0.625rem; + gap: 0.5rem; } +/* 댓글 하나 */ .commentItem { display: flex; flex-direction: column; gap: 0.2rem; } +/* 작성자 이름 */ .commentAuthor { font-family: var(--font-main); - font-size: 0.8125rem; + font-size: 0.85rem; font-weight: 600; - color: var(--gray600); + color: var(--black); display: flex; align-items: center; - gap: 0.375rem; + gap: 0.25rem; } -.staffBadge { - background: #1976d2; - color: var(--white); - font-size: 0.625rem; - padding: 1px 0.375rem; - border-radius: 10px; - font-weight: 600; +/* 운영진 이름은 파란색 */ +.staffAuthor { + color: #1565c0; } -.commentBody { - display: flex; - align-items: center; - gap: 0.375rem; - padding-left: 0.25rem; -} - -.commentArrow { - color: var(--gray600); - font-size: 0.875rem; +/* 운영진 배지 */ +.staffBadge { + font-size: 0.7rem; } -.commentText { +/* 댓글 내용 */ +.commentContent { font-family: var(--font-main); font-size: 0.875rem; - color: var(--black); + color: var(--gray600, #616161); + background: var(--gray200, #f5f5f5); + border-radius: 8px; + padding: 0.5rem 0.75rem; + line-height: 1.5; } -.attachmentBtn { - background: #f0f4ff; - border: 1px solid #c5d0f0; - border-radius: 6px; - padding: 2px 0.5rem; +/* 3개 초과 시 "외 N개 댓글" 안내 텍스트 */ +/* 상세 페이지에서 전체 댓글을 볼 수 있으므로 버튼이 아닌 텍스트로만 표시 */ +.commentMore { font-family: var(--font-main); - font-size: 0.75rem; - color: #1976d2; - cursor: pointer; + font-size: 0.8rem; + color: var(--gray600, #9e9e9e); + padding-left: 0.25rem; } -/* ── Comment Input ──────────────────────────────────────────────── */ + +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 댓글 입력창 (댓글달기 버튼 눌렀을 때만 노출) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + .commentInputRow { display: flex; align-items: center; gap: 0.5rem; - margin-top: 0.75rem; - border-top: 1px solid var(--gray200); - padding-top: 0.625rem; + border-top: 1px solid var(--gray200, #eeeeee); + padding: 0.6rem 1rem; + background: var(--white); + /* commentPreview 없이 바로 열릴 수도 있으니 border-top으로 구분 */ } +/* 입력창 내부 텍스트 필드 */ .commentInput { flex: 1; - border: 1px solid var(--gray200); + border: 1.5px solid var(--gray200, #e0e0e0); border-radius: 20px; - padding: 0.5rem 0.875rem; + outline: none; font-family: var(--font-main); font-size: 0.875rem; - outline: none; color: var(--black); - background: #fafafa; + background: transparent; + padding: 0.35rem 0.85rem; + transition: border-color 0.15s; } .commentInput:focus { - border-color: var(--dark); - background: var(--white); + border-color: #5cba5c; } -.commentSubmit { - width: 2rem; - height: 2rem; - border-radius: 50%; - background: var(--dark); - color: var(--white); +.commentInput::placeholder { + color: var(--gray600, #9e9e9e); +} + +/* 댓글 제출 버튼 */ +.submitBtn { + background: #5cba5c; + color: white; border: none; + border-radius: 50%; + width: 1.75rem; + height: 1.75rem; font-size: 1rem; - font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - transition: opacity 0.15s; + transition: background 0.15s; } -.commentSubmit:hover { - opacity: 0.8; +.submitBtn:hover { + background: #4cae4c; } -/* ── Floating Bar ───────────────────────────────────────────────── */ -.floatingBar { + +/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 하단 고정: 새 질문 입력창 + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ + +.newQuestionBar { + /* 화면 하단에 고정 */ position: fixed; - bottom: 1rem; + bottom: 0; left: 50%; transform: translateX(-50%); - width: calc(100% - 3rem); - max-width: 850px; + /* 가로 중앙 정렬 */ + width: 100%; + max-width: 880px; + background: var(--white); - border-radius: 28px; - box-shadow: 4px 4px 12px 0 rgba(0, 0, 0, 0.15); + border-top: 1px solid var(--gray200, #eeeeee); + padding: 0.6rem 1rem 0.75rem; + display: flex; + flex-direction: column; + /* 에러메시지(위) + 입력행(아래) 세로 배치 */ + gap: 0.35rem; + box-sizing: border-box; +} + +/* 에러 메시지 (API 실패 시) */ +.errorMsg { + font-family: var(--font-main); + font-size: 0.8rem; + color: #e53935; + margin: 0; + text-align: center; +} + +/* + 아이콘 + input + 버튼 가로 배치 wrapper */ +.newQuestionInputRow { display: flex; align-items: center; - padding: 0.5rem 0.5rem 0.5rem 0.875rem; gap: 0.5rem; } -.floatingPlus { - font-size: 1.125rem; - color: var(--gray600); +/* 비활성화 상태 (요청 중) */ +.newQuestionInput:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.newQuestionSubmit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.newQuestionPlus { + font-size: 1.2rem; + color: var(--gray600, #9e9e9e); flex-shrink: 0; - cursor: pointer; - font-weight: 300; } -.floatingInput { +.newQuestionInput { flex: 1; border: none; outline: none; font-family: var(--font-main); - font-size: 0.9375rem; + font-size: 0.95rem; color: var(--black); background: transparent; } -.floatingInput::placeholder { - color: var(--gray600); +.newQuestionInput::placeholder { + color: var(--gray600, #bdbdbd); } -.floatingSubmit { - width: 2.25rem; - height: 2.25rem; - border-radius: 50%; - background: var(--dark); - color: var(--white); +.newQuestionSubmit { + background: #5cba5c; + color: white; border: none; - font-size: 1rem; - font-weight: 700; + border-radius: 50%; + width: 2rem; + height: 2rem; + font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; - transition: opacity 0.15s; -} - -.floatingSubmit:hover:not(:disabled) { - opacity: 0.8; + transition: background 0.15s; } -.floatingSubmit:disabled { - opacity: 0.4; - cursor: default; +.newQuestionSubmit:hover { + background: #4cae4c; } \ No newline at end of file From 4b4004038b12da549b912364e1efcf8d1f959c8d Mon Sep 17 00:00:00 2001 From: kdhye1119 Date: Sun, 17 May 2026 15:36:43 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[fix]=EC=A7=88=EB=AC=B8=20=EB=B6=80?= =?UTF-8?q?=EC=9B=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9D=BC=EB=B6=80(js?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=99=84=EC=84=B1,=20css=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/pages/qna/QnAListPage.js | 288 ++++------- frontend/src/pages/qna/QnAListPage.module.css | 456 ++++++++---------- 4 files changed, 308 insertions(+), 447 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2dde982..6e43dfa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^13.5.0", "axios": "^1.15.2", + "lucide-react": "^1.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-icons": "^5.6.0", @@ -11170,6 +11171,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index cbfb176..d552f49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^13.5.0", "axios": "^1.15.2", + "lucide-react": "^1.16.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-icons": "^5.6.0", diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index bf8e4d7..345eaff 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -2,6 +2,57 @@ import { useState } from 'react'; import styles from './QnAListPage.module.css'; import { FiChevronLeft, FiChevronRight, FiMessageSquare } from 'react-icons/fi'; +const CommentImoji = () => ( + + + + +); + +const MeCuriousToo = () => ( + + + + + + + + +); + +const StaffCheck = () => ( + + + + + +); + +const SortBtn = () => ( + +); + +const OBtn = () => ( + + + + + + + + +); + +const XBtn = () => ( + + + + +); const UNDERSTAND = ['이해했다', '성공했다']; @@ -57,29 +108,6 @@ const MOCK_QUESTIONS = [ ]; -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -// 메인 컴포넌트 -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -// props 설명: -// sessionTitle → 상단에 보여줄 세션 제목 (QnAMainPage에서 넘겨줄 거야) -// onBack → 뒤로가기 버튼 눌렀을 때 실행할 함수 (선택) -// onCardClick → 카드 클릭 시 상세 페이지로 이동할 함수 (questionId를 인자로 받아) -// -// 사용 예시 (QnAMainPage에서): -// navigate(-1)} -// onCardClick={(id) => navigate(`/qna/${sessionId}/question/${id}`)} -// /> -// -// React Router 쓴다면 QnAMainPage 카드 onClick에 이렇게: -// onClick={() => navigate('/qna/1', { state: { title: session.title } })} -// 그리고 이 컴포넌트에서: const { state } = useLocation(); - -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -// API 설정 -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -90,11 +118,7 @@ function QnAListPage({ onCardClick, // 카드 클릭 시 상세 페이지 이동 (questionId를 인자로 받아) }) { - // ────────────────────────────────────── - // 📌 useState 사용법: - // const [현재값, 값을바꾸는함수] = useState(초기값); - // 값을바꾸는함수(새값) 호출하면 화면이 자동으로 다시 그려져 - // ────────────────────────────────────── + // 현재 보고 있는 이해도 체크 인덱스 (0 = '이해했다') const [understandIndex, setUnderstandIndex] = useState(0); @@ -103,7 +127,7 @@ function QnAListPage({ const [filterCurious, setFilterCurious] = useState(false); // 정렬 방식 - const [sortOrder, setSortOrder] = useState('최신순'); + const [sortOrder, setSortOrder] = useState('정렬'); // 정렬 드롭다운 열려 있는지 const [showSortMenu, setShowSortMenu] = useState(false); @@ -132,61 +156,48 @@ function QnAListPage({ const [submitError, setSubmitError] = useState(null); - // ────────────────────────────────────── - // 이벤트 핸들러 함수들 - // ────────────────────────────────────── - - // < 버튼: 이전 이해도 체크로 이동 + // 이해도 const goPrevUnderstand = () => { - if (understandIndex > 0) { - setUnderstandIndex(prev => prev - 1); - } + if (understandIndex > 0) setUnderstandIndex(prev => prev - 1); }; - // > 버튼: 다음 이해도 체크로 이동 const goNextUnderstand = () => { - setUnderstandIndex(prev => (prev + 1) % UNDERSTAND.length); + if (understandIndex < UNDERSTAND.length - 1) setUnderstandIndex(prev => prev + 1); }; - // 좋아요 버튼 토글 + // 질문 const toggleLike = (e, id) => { - e.stopPropagation(); // 카드 클릭(상세 이동) 방지 + e.stopPropagation(); setQuestions(prev => - // prev = 이전 질문 목록 배열 - // map으로 전체 돌면서 해당 id의 질문만 변경 prev.map(q => q.id === id ? { - ...q, // 나머지 필드는 그대로 복사 (spread 연산자) + ...q, iLiked: !q.iLiked, likes: q.iLiked ? q.likes - 1 : q.likes + 1, } - : q // 다른 질문은 그대로 + : q ) ); }; - // '댓글달기' 버튼 클릭: 해당 질문의 댓글 입력창 열기/닫기 토글 - // 이미 열려 있는 질문이면 닫고, 아니면 해당 id로 열기 const toggleCommentInput = (e, questionId) => { - e.stopPropagation(); // 카드 클릭(상세 이동) 방지 + e.stopPropagation(); setCommentOpenId(prev => prev === questionId ? null : questionId); }; - // 댓글 입력창 텍스트 변경 const handleCommentChange = (questionId, value) => { setCommentInputs(prev => ({ - ...prev, // 기존 다른 질문의 입력값은 유지 - [questionId]: value, // 이 질문의 입력값만 업데이트 + ...prev, + [questionId]: value, })); }; - // 댓글 제출 (엔터 or 버튼 클릭) const handleCommentSubmit = (e, questionId) => { - e.stopPropagation(); // 카드 클릭(상세 이동) 방지 + e.stopPropagation(); const text = (commentInputs[questionId] || '').trim(); - if (!text) return; // 빈 댓글은 무시 + if (!text) return; setQuestions(prev => prev.map(q => @@ -195,79 +206,62 @@ function QnAListPage({ ...q, comments: [ ...q.comments, - // Date.now()로 임시 고유 id 생성 { id: Date.now(), author: '나', isStaff: false, content: text }, ], } : q ) ); - - // 입력창 비우기 + 댓글 입력창 닫기 setCommentInputs(prev => ({ ...prev, [questionId]: '' })); setCommentOpenId(null); }; - // ✅ 새 질문 등록 함수 - // async: 비동기 함수 선언 키워드 (await를 쓰려면 필요해) - // 나중에 실제 API 연결할 때 await axios.post(...) 식으로 사용하게 됨 + + const handleNewQuestion = async () => { - const text = newQuestion.trim(); // 앞뒤 공백 제거 - if (!text) return; // 빈 질문이면 무시 + const text = newQuestion.trim(); + if (!text) return; - setIsSubmitting(true); // 버튼 비활성화 (중복 제출 방지) - setSubmitError(null); // 이전 에러 메시지 초기화 + setIsSubmitting(true); + setSubmitError(null); try { - // TODO: 실제 API 연결 시 아래 주석 풀기 - // await axios.post(`/api/sessions/${sessionId}/questions`, { content: text }); - - // 지금은 API 없이 바로 화면에 추가 (임시) - // ...prev = 기존 질문 목록 유지, 새 질문을 맨 앞에 추가 setQuestions(prev => [ { id: Date.now(), text, likes: 0, iLiked: false, image: null, comments: [] }, ...prev, ]); - setNewQuestion(''); // 입력창 비우기 + setNewQuestion(''); } catch (error) { - // 네트워크 오류 or 서버 오류 처리 console.error('질문 등록 실패:', error); setSubmitError('질문 등록에 실패했어요.'); } finally { - setIsSubmitting(false); // 버튼 다시 활성화 + setIsSubmitting(false); } }; - // ────────────────────────────────────── - // 렌더링에 쓸 계산값들 - // ────────────────────────────────────── - // 현재 이해도 이름 + const currentUnderstand = UNDERSTAND[understandIndex]; // 저도 궁금해요 필터가 켜져 있으면 필터링 const displayedQuestions = filterCurious - ? questions.filter(q => q.iCurious) + ? questions.filter(q => q.iLiked) : questions; + + + return ( - // 전체 페이지 컨테이너 - // 하단 새 질문 입력창 높이만큼 padding-bottom 줘야 내용이 가려지지 않아 + // 상단
- {/* ── 세션 제목 ── */}

{sessionTitle}

- - {/* ── 필터/정렬 행 ── */}
- - {/* 저도 궁금해요 체크박스 */} - {/* label 안에 input 넣으면 label 클릭해도 체크박스가 토글돼 */}
+
- {/* ── 이해도 체크 바 ── */} + {/* 이해도 */}
- {/* 이전 이해도 체크 버튼 */} - - {/* 이해도 체크 이름 + 카운트 */} {currentUnderstand} (13/29) - {/* TODO: 실제 카운트는 API에서 받아와야 해 */} - - {/* O 버튼: 이해했어요 */} - {/* 눌린 상태면 oxActive 클래스 추가해서 스타일 변경 */} - {/* JSX에서 여러 클래스 합치기: `${styles.a} ${조건 ? styles.b : ''}` */} + - - {/* X 버튼: 모르겠어요 */} - - {/* 다음 이해도 체크 버튼 */}
{/* ── 질문 목록 ── */}
- - {/* displayedQuestions 배열을 map으로 돌면서 카드 하나씩 렌더링 */} {displayedQuestions.map(question => (
onCardClick?.(question.id)} > - {/* 질문 헤더 */}
- - {/* Q. 아이콘 */} Q. - - {/* 질문 텍스트 */} {question.text} - {/* 오른쪽 액션 버튼들 */}
- - {/* 좋아요 버튼 */} - - {/* 댓글달기 버튼: 클릭하면 해당 질문의 댓글 입력창 열기/닫기 */}
+ {question.image && ( + 첨부 이미지 e.stopPropagation()} + /> + )} - {/* ── 댓글 미리보기 (항상 노출, 최대 MAX_VISIBLE_COMMENTS개) ── - 댓글이 있을 때만 렌더링 - slice(0, MAX_VISIBLE_COMMENTS)로 앞 3개만 잘라서 표시 */} {question.comments.length > 0 && (
{question.comments.slice(0, MAX_VISIBLE_COMMENTS).map(comment => (
- {/* 작성자 이름 (운영진이면 파란색으로 강조) */} - + {comment.author} {comment.isStaff && ( - 🔵 + )} {/* 댓글 내용 */} @@ -418,8 +387,7 @@ function QnAListPage({
))} - {/* 댓글이 3개를 초과하면 "외 N개" 텍스트로 나머지 개수만 안내 */} - {/* 상세 페이지에서 전체 댓글을 볼 수 있으므로 더보기 버튼 없이 텍스트만 표시 */} + {question.comments.length > MAX_VISIBLE_COMMENTS && ( 외 {question.comments.length - MAX_VISIBLE_COMMENTS}개 댓글 @@ -427,14 +395,10 @@ function QnAListPage({ )}
)} - - {/* ── 댓글 입력창 ── - commentOpenId === question.id 일 때만 렌더링 - (조건이 false면 아무것도 그리지 않아) */} {commentOpenId === question.id && (
e.stopPropagation()} // 카드 클릭(상세 이동) 방지 + onClick={e => e.stopPropagation()} > { if (e.key === 'Enter') handleCommentSubmit(e, question.id); }} - autoFocus // 입력창 열리면 자동으로 포커스 + autoFocus />
@@ -499,36 +457,4 @@ function QnAListPage({ ); } -export default QnAListPage; - - -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -// QnAMainPage에서 이 페이지로 이동하는 방법 -// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━═══ -// -// ① React Router 설치: -// npm install react-router-dom -// -// ② App.jsx (또는 라우터 설정 파일) 에서: -// import { BrowserRouter, Routes, Route } from 'react-router-dom'; -// import QNAMainPage from './QNAMainPage'; -// import QnADetailPage from './QnADetailPage'; -// -// -// -// } /> -// } /> -// -// -// -// ③ QnAMainPage.jsx 카드/리스트 클릭 시: -// import { useNavigate } from 'react-router-dom'; -// const navigate = useNavigate(); -// -// // 카드 onClick: -// onClick={() => navigate(`/qna/${session.id}`, { state: { title: `${session.week} 세션(${session.title})` } })} -// -// ④ 이 컴포넌트에서 제목 받아오려면: -// import { useLocation } from 'react-router-dom'; -// const { state } = useLocation(); -// // sessionTitle prop 대신: state?.title \ No newline at end of file +export default QnAListPage; \ No newline at end of file diff --git a/frontend/src/pages/qna/QnAListPage.module.css b/frontend/src/pages/qna/QnAListPage.module.css index c6b04f5..5ad56dc 100644 --- a/frontend/src/pages/qna/QnAListPage.module.css +++ b/frontend/src/pages/qna/QnAListPage.module.css @@ -1,423 +1,366 @@ -/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - QnAListPage.module.css - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - QnAMainPage와 같은 CSS 변수(--font-main, --black 등)를 사용하니까 - 전역 CSS에 변수가 정의돼 있어야 해 -*/ - - -/* ── 전체 페이지 컨테이너 ── */ .page { + display: flex; + flex-direction: column; min-height: 100vh; - max-width: 880px; + max-width: 780px; margin: 0 auto; - /* 하단 고정 입력창 높이만큼 아래 패딩 줘야 내용이 가려지지 않아 */ - padding: 2rem 1rem 5rem; - position: relative; + padding-bottom: 80px; } - -/* ── 세션 제목 ── */ +/* 상단 */ .title { font-family: var(--font-main); - font-size: 1.6rem; - font-weight: 700; - color: var(--black); + font-size: 2rem; text-align: center; - margin-bottom: 1.5rem; + margin: 10px; + color: var(--black); + padding-top: 60px; + font-weight: 700; + line-height: normal; } -/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 필터 / 정렬 행 - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ - .filterRow { display: flex; align-items: center; - justify-content: space-between; - /* 왼쪽 체크박스 / 오른쪽 정렬버튼 */ - margin-bottom: 0.75rem; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.5rem 1rem; } -/* 저도 궁금해요 체크박스 + 텍스트 묶음 */ .curiousLabel { display: flex; align-items: center; - gap: 0.4rem; + gap: 0.3rem; font-family: var(--font-main); - font-size: 0.9rem; - color: var(--black); + font-size: 0.8rem; + color: var(--gray600); cursor: pointer; - user-select: none; - /* 레이블 드래그 선택 방지 */ } .curiousCheckbox { - width: 0.95rem; - height: 0.95rem; + accent-color: var(--dark); + width: 14px; + height: 14px; cursor: pointer; - accent-color: var(--dark, #3cb371); - /* 체크박스 색상 */ } -/* 정렬 드롭다운 wrapper - position:relative로 자식의 absolute 기준점이 됨 */ .sortWrapper { position: relative; } .sortBtn { background: none; - border: 1px solid var(--gray200, #e0e0e0); + border: 1px solid var(--gray600); border-radius: 6px; - padding: 0.3rem 0.7rem; + padding: 0.25rem 0.6rem; font-family: var(--font-main); - font-size: 0.875rem; + font-size: 0.8rem; + color: var(--gray600); cursor: pointer; - color: var(--black); - transition: background 0.15s; -} - -.sortBtn:hover { - background: var(--gray200, #f5f5f5); + white-space: nowrap; + border-radius: 20px; } -/* 드롭다운 메뉴 - sortBtn 아래에 떠있어 */ .sortMenu { position: absolute; right: 0; top: calc(100% + 4px); - /* 버튼 바로 아래 */ background: var(--white); - border: 1px solid var(--gray200, #e0e0e0); + border: 1px solid var(--gray200); border-radius: 8px; - box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.12); + box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12); list-style: none; - padding: 0.3rem 0; margin: 0; - z-index: 100; - /* 다른 요소 위로 올라오게 */ - min-width: 90px; + padding: 0.25rem 0; + z-index: 10; + min-width: 110px; } .sortOption { padding: 0.5rem 1rem; font-family: var(--font-main); - font-size: 0.875rem; - cursor: pointer; + font-size: 0.85rem; color: var(--black); - transition: background 0.1s; + cursor: pointer; + white-space: nowrap; } -.sortOption:hover { - background: var(--gray200, #f5f5f5); +.divider { + background: var(--gray200); + width: 100%; + height: 1px; + border: none; } - -/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 이해도 체크 바 (이해했다 < >) - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ - +/*이해도*/ .understandBar { + margin-top: 20px; display: flex; align-items: center; - gap: 0.5rem; - background: var(--white); + justify-content: space-between; + margin-bottom: 20px; border-radius: 10px; - padding: 0.7rem 1rem; - box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 0.12); - margin-bottom: 1.25rem; + border: 1px solid var(--dark); + background: var(--white); + box-shadow: 1px 2px 3px 0 rgba(0, 0, 0, 0.25); + width: 768px; + height: 56px; } -/* < > 화살표 버튼 */ .arrowBtn { background: none; border: none; cursor: pointer; - color: var(--black); + color: var(--gray600); display: flex; align-items: center; - padding: 0.2rem; - border-radius: 4px; - transition: background 0.15s; - flex-shrink: 0; + padding: 0; + line-height: 1; } -.arrowBtn:hover { - background: var(--gray200, #f0f0f0); +.arrowBtn:disabled { + opacity: 0.3; + cursor: default; } -/* 이해도 체크 이름 */ .understandName { flex: 1; - /* 남은 공간 차지 */ + text-align: left; font-family: var(--font-main); - font-size: 1rem; - font-weight: 600; + font-size: 24px; + font-weight: 500; color: var(--black); - text-align: center; } -/* 이해도 카운트 (13/29) */ .understandCount { - font-weight: 400; - font-size: 0.875rem; - color: var(--gray600, #757575); + font-weight: 300; + color: var(--black); + font-size: 18px; } -/* O / X 공통 스타일 */ .oxBtn { - background: none; - border: 1.5px solid var(--gray200, #e0e0e0); - border-radius: 50%; - width: 2rem; - height: 2rem; - font-size: 0.875rem; + border: none; + font-size: 0.85rem; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; - transition: all 0.15s; - flex-shrink: 0; -} - -/* O 버튼 (초록) */ -.oxO { - color: #3cb371; -} - -.oxO:hover { - border-color: #3cb371; - background: rgba(60, 179, 113, 0.08); + transition: background 0.15s, border-color 0.15s; + margin: 0 0.2rem; + width: 36px; + height: 36px; + aspect-ratio: 1/1; + border-radius: 10px; + background: var(--gray200); } -/* X 버튼 (회색/빨강) */ +.oxO, .oxX { - color: var(--gray600, #9e9e9e); + color: var(--gray600); } -.oxX:hover { - border-color: #e57373; - color: #e57373; - background: rgba(229, 115, 115, 0.08); -} - -/* 눌린 상태 */ -.oxActive { - border-color: currentColor; - background: rgba(0, 0, 0, 0.06); +.oxO.oxActive, +.oxX.oxActive { + background: var(--light); + color: var(--dark); } -/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 질문 목록 - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ +/*질문*/ +/* 다음에 할 때 여기부터 하기 */ .questionList { display: flex; flex-direction: column; - gap: 0.75rem; + gap: 20px; + padding: 0 1rem; } -/* 질문 카드 하나 */ .questionCard { - background: var(--white); - border-radius: 10px; - box-shadow: 2px 2px 5px 0 rgba(0, 0, 0, 0.15); - overflow: hidden; - /* 내부 요소가 border-radius 밖으로 안 튀어나오게 */ + padding: 0.85rem 1rem; cursor: pointer; transition: box-shadow 0.2s; + border-radius: 30px; + background: var(--white); + box-shadow: 1px 2px 3px 0 rgba(0, 0, 0, 0.25); + width: 720px; + min-height: 40px; } .questionCard:hover { - box-shadow: 4px 4px 10px 0 rgba(0, 0, 0, 0.18); + box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.15); } -/* 질문 헤더 (항상 보이는 부분) */ + .questionHeader { display: flex; align-items: center; - gap: 0.6rem; - padding: 0.85rem 1rem; + gap: 0.4rem; } -/* Q. 아이콘 */ .qIcon { - font-family: var(--font-main); - font-size: 1.05rem; - font-weight: 700; - color: #5cba5c; flex-shrink: 0; + padding-top: 1px; + color: var(--dark); + font-family: var(--font-main); + font-size: 36px; + font-weight: 900; + line-height: normal; } -/* 질문 텍스트 */ .questionText { flex: 1; - /* 남은 공간 차지 */ font-family: var(--font-main); - font-size: 1rem; color: var(--black); - line-height: 1.4; + word-break: keep-all; + font-size: 20px; + font-weight: 500; + line-height: normal; } -/* 오른쪽 버튼 묶음 */ +/* 여기부터인 것 같기도 하고 */ + +/* 좋아요 + 댓글달기 버튼 묶음 */ .questionActions { display: flex; align-items: center; - gap: 0.75rem; + gap: 0.6rem; flex-shrink: 0; - /* 텍스트가 길어도 버튼은 줄어들지 않음 */ + margin-left: 0.5rem; } -/* 좋아요 버튼 */ .likeBtn { + display: flex; + align-items: center; + gap: 3px; background: none; border: none; - font-size: 0.875rem; cursor: pointer; - color: var(--gray600, #757575); - display: flex; - align-items: center; - gap: 0.2rem; - transition: color 0.15s; + font-family: var(--font-main); + font-size: 0.8rem; + color: var(--gray600); padding: 0; + transition: color 0.15s; } -/* 좋아요 누른 상태 */ .likeBtn.liked { - color: #2e7d32; - font-weight: 600; + color: #09C410; } -/* 댓글달기 버튼 */ .commentBtn { + display: flex; + align-items: center; + gap: 2px; background: none; border: none; - font-size: 0.8rem; cursor: pointer; - color: var(--gray600, #757575); - display: flex; - align-items: center; - gap: 0.2rem; - white-space: nowrap; - /* 줄바꿈 방지 */ - transition: color 0.15s; + font-family: var(--font-main); + font-size: 0.8rem; + color: var(--gray600); padding: 0; + white-space: nowrap; } -.commentBtn:hover, -.likeBtn:hover { +.commentBtn:hover { color: var(--black); } +/* 카드 안 이미지 */ +.questionImage { + width: 100%; + border-radius: 8px; + margin-top: 0.65rem; + object-fit: cover; + display: block; +} -/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 댓글 미리보기 (카드에서 항상 노출, 최대 3개) - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ - +/* ── 댓글 미리보기 ── */ .commentPreview { - border-top: 1px solid var(--gray200, #eeeeee); - padding: 0.6rem 1rem 0.75rem; + margin-top: 0.7rem; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.4rem; } -/* 댓글 하나 */ .commentItem { display: flex; flex-direction: column; - gap: 0.2rem; + gap: 0.15rem; } -/* 작성자 이름 */ .commentAuthor { font-family: var(--font-main); - font-size: 0.85rem; + font-size: 0.78rem; font-weight: 600; color: var(--black); display: flex; align-items: center; - gap: 0.25rem; -} - -/* 운영진 이름은 파란색 */ -.staffAuthor { - color: #1565c0; + gap: 3px; } -/* 운영진 배지 */ .staffBadge { - font-size: 0.7rem; + display: inline-flex; + align-items: center; } -/* 댓글 내용 */ .commentContent { font-family: var(--font-main); - font-size: 0.875rem; - color: var(--gray600, #616161); - background: var(--gray200, #f5f5f5); - border-radius: 8px; - padding: 0.5rem 0.75rem; - line-height: 1.5; + font-size: 0.82rem; + color: var(--gray600); + background: #f5f5f5; + border-radius: 6px; + padding: 0.3rem 0.6rem; + line-height: 1.4; } -/* 3개 초과 시 "외 N개 댓글" 안내 텍스트 */ -/* 상세 페이지에서 전체 댓글을 볼 수 있으므로 버튼이 아닌 텍스트로만 표시 */ .commentMore { font-family: var(--font-main); - font-size: 0.8rem; - color: var(--gray600, #9e9e9e); - padding-left: 0.25rem; + font-size: 0.78rem; + color: var(--gray600); + cursor: pointer; + text-decoration: underline; + margin-top: 0.1rem; } +.commentMore:hover { + color: var(--black); +} -/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 댓글 입력창 (댓글달기 버튼 눌렀을 때만 노출) - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ - +/* ── 댓글 입력 행 ── */ .commentInputRow { display: flex; align-items: center; - gap: 0.5rem; - border-top: 1px solid var(--gray200, #eeeeee); - padding: 0.6rem 1rem; - background: var(--white); - /* commentPreview 없이 바로 열릴 수도 있으니 border-top으로 구분 */ + gap: 0.4rem; + margin-top: 0.65rem; } -/* 입력창 내부 텍스트 필드 */ .commentInput { flex: 1; - border: 1.5px solid var(--gray200, #e0e0e0); + border: 1px solid var(--gray200); border-radius: 20px; - outline: none; + padding: 0.4rem 0.8rem; font-family: var(--font-main); - font-size: 0.875rem; + font-size: 0.82rem; color: var(--black); - background: transparent; - padding: 0.35rem 0.85rem; + background: #f5f5f5; + outline: none; transition: border-color 0.15s; } .commentInput:focus { - border-color: #5cba5c; -} - -.commentInput::placeholder { - color: var(--gray600, #9e9e9e); + border-color: #09C410; + background: var(--white); } -/* 댓글 제출 버튼 */ .submitBtn { - background: #5cba5c; - color: white; - border: none; + width: 30px; + height: 30px; border-radius: 50%; - width: 1.75rem; - height: 1.75rem; + background: #09C410; + border: none; + color: var(--white); font-size: 1rem; cursor: pointer; display: flex; @@ -428,89 +371,65 @@ } .submitBtn:hover { - background: #4cae4c; + background: #07a50e; } - -/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 하단 고정: 새 질문 입력창 - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */ - +/* ── 하단 새 질문 입력바 ── */ .newQuestionBar { - /* 화면 하단에 고정 */ position: fixed; bottom: 0; left: 50%; transform: translateX(-50%); - /* 가로 중앙 정렬 */ width: 100%; - max-width: 880px; - + max-width: 480px; background: var(--white); - border-top: 1px solid var(--gray200, #eeeeee); - padding: 0.6rem 1rem 0.75rem; - display: flex; - flex-direction: column; - /* 에러메시지(위) + 입력행(아래) 세로 배치 */ - gap: 0.35rem; - box-sizing: border-box; + border-top: 1px solid var(--gray200); + padding: 0.6rem 1rem; + z-index: 100; } -/* 에러 메시지 (API 실패 시) */ .errorMsg { font-family: var(--font-main); - font-size: 0.8rem; + font-size: 0.78rem; color: #e53935; - margin: 0; - text-align: center; + margin: 0 0 0.3rem 0.5rem; } -/* + 아이콘 + input + 버튼 가로 배치 wrapper */ .newQuestionInputRow { display: flex; align-items: center; gap: 0.5rem; } -/* 비활성화 상태 (요청 중) */ -.newQuestionInput:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.newQuestionSubmit:disabled { - opacity: 0.5; - cursor: not-allowed; -} - .newQuestionPlus { font-size: 1.2rem; - color: var(--gray600, #9e9e9e); + color: var(--gray600); flex-shrink: 0; + line-height: 1; } .newQuestionInput { flex: 1; border: none; - outline: none; + background: none; font-family: var(--font-main); - font-size: 0.95rem; + font-size: 0.88rem; color: var(--black); - background: transparent; + outline: none; } .newQuestionInput::placeholder { - color: var(--gray600, #bdbdbd); + color: var(--gray600); } .newQuestionSubmit { - background: #5cba5c; - color: white; - border: none; + width: 32px; + height: 32px; border-radius: 50%; - width: 2rem; - height: 2rem; - font-size: 1.1rem; + background: #09C410; + border: none; + color: var(--white); + font-size: 1rem; cursor: pointer; display: flex; align-items: center; @@ -520,5 +439,10 @@ } .newQuestionSubmit:hover { - background: #4cae4c; + background: #07a50e; +} + +.newQuestionSubmit:disabled { + background: var(--gray200); + cursor: default; } \ No newline at end of file From fd55e8ca74294c79059832ef572fe2d6027efa2e Mon Sep 17 00:00:00 2001 From: kdhye1119 Date: Mon, 18 May 2026 11:47:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[feat]=20=EB=8C=80=EB=8C=93=EA=B8=80?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=99=84?= =?UTF-8?q?=EC=84=B1,=20=EA=B8=80=20=EC=9E=85=EB=A0=A5=EC=B0=BD=20?= =?UTF-8?q?=ED=95=B4=EC=95=BC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnAListPage.js | 41 +++++----- frontend/src/pages/qna/QnAListPage.module.css | 75 +++++++++++++------ 2 files changed, 73 insertions(+), 43 deletions(-) diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 345eaff..bacd730 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -1,25 +1,21 @@ import { useState } from 'react'; import styles from './QnAListPage.module.css'; -import { FiChevronLeft, FiChevronRight, FiMessageSquare } from 'react-icons/fi'; +import { FiChevronLeft, FiChevronRight } from 'react-icons/fi'; const CommentImoji = () => ( - - - - + + + + ); const MeCuriousToo = () => ( - - + + - + ); @@ -29,20 +25,20 @@ const StaffCheck = () => ( - + ); const SortBtn = () => ( - + ); const OBtn = () => ( - + - + ); @@ -54,6 +50,10 @@ const XBtn = () => ( ); +const CommentCommentArraw = () => ( + +); + const UNDERSTAND = ['이해했다', '성공했다']; // 댓글 최대 표시 개수 (카드에서 항상 노출) @@ -370,10 +370,11 @@ function QnAListPage({ /> )} + {question.comments.length > 0 && (
{question.comments.slice(0, MAX_VISIBLE_COMMENTS).map(comment => ( -
+
{comment.author} {comment.isStaff && ( @@ -381,8 +382,10 @@ function QnAListPage({ )} {/* 댓글 내용 */} -
- ↳ {comment.content} +
+
+ {comment.content} +
))} diff --git a/frontend/src/pages/qna/QnAListPage.module.css b/frontend/src/pages/qna/QnAListPage.module.css index 5ad56dc..95a2d54 100644 --- a/frontend/src/pages/qna/QnAListPage.module.css +++ b/frontend/src/pages/qna/QnAListPage.module.css @@ -170,12 +170,11 @@ /*질문*/ -/* 다음에 할 때 여기부터 하기 */ .questionList { display: flex; flex-direction: column; gap: 20px; - padding: 0 1rem; + } .questionCard { @@ -203,7 +202,7 @@ .qIcon { flex-shrink: 0; padding-top: 1px; - color: var(--dark); + color: var(--main); font-family: var(--font-main); font-size: 36px; font-weight: 900; @@ -220,9 +219,6 @@ line-height: normal; } -/* 여기부터인 것 같기도 하고 */ - -/* 좋아요 + 댓글달기 버튼 묶음 */ .questionActions { display: flex; align-items: center; @@ -232,42 +228,49 @@ } .likeBtn { + justify-content: center; display: flex; align-items: center; - gap: 3px; - background: none; + gap: 7px; border: none; cursor: pointer; font-family: var(--font-main); - font-size: 0.8rem; + font-size: 15px; color: var(--gray600); - padding: 0; + padding: 0 3px 0 0; transition: color 0.15s; + width: 37.5px; + height: 25px; + background-color: var(--gray50); + border-radius: 10px; + } .likeBtn.liked { - color: #09C410; + color: var(--dark); + background-color: var(--light); } .commentBtn { + justify-content: center; display: flex; align-items: center; gap: 2px; - background: none; border: none; cursor: pointer; font-family: var(--font-main); - font-size: 0.8rem; + font-size: 15px; color: var(--gray600); padding: 0; white-space: nowrap; -} + border-radius: 10px; + background: var(--gray50); + width: 87px; + height: 25px; -.commentBtn:hover { - color: var(--black); } -/* 카드 안 이미지 */ + .questionImage { width: 100%; border-radius: 8px; @@ -276,28 +279,49 @@ display: block; } + /* ── 댓글 미리보기 ── */ .commentPreview { - margin-top: 0.7rem; + margin-top: 26px; display: flex; flex-direction: column; gap: 0.4rem; + padding-left: 20px; + border-top: 1px solid var(--gray200); + padding-top: 17px; + } +.commentWrapper { + display: flex; + flex-direction: column; + gap: 0.15rem; + +} + + + .commentItem { display: flex; flex-direction: column; gap: 0.15rem; + max-width: 646px; + border-radius: 5px 20px 20px 20px; + background: var(--gray50); + padding: 0.5rem 0.8rem; + + } .commentAuthor { font-family: var(--font-main); font-size: 0.78rem; - font-weight: 600; + font-weight: 500; color: var(--black); display: flex; align-items: center; gap: 3px; + line-height: normal; } .staffBadge { @@ -308,11 +332,12 @@ .commentContent { font-family: var(--font-main); font-size: 0.82rem; - color: var(--gray600); - background: #f5f5f5; - border-radius: 6px; - padding: 0.3rem 0.6rem; - line-height: 1.4; + color: var(--black); + font-weight: 400; + line-height: normal; + display: flex; + align-items: center; + gap: 4px; } .commentMore { @@ -328,6 +353,8 @@ color: var(--black); } +/* 여기부터 시작 */ + /* ── 댓글 입력 행 ── */ .commentInputRow { display: flex;