diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 21f1f7b..6c94259 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,8 +41,10 @@ jobs: - name: Build and push Docker image working-directory: backend run: | - docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:latest . + docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:latest \ + -t ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:${{ github.sha }} . docker push ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:latest + docker push ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:${{ github.sha }} - name: Deploy to EC2 uses: appleboy/ssh-action@v1.0.0 @@ -59,7 +61,7 @@ jobs: --restart unless-stopped \ -p 8080:8080 \ -v piroin-uploads:/app/uploads \ - --health-cmd="curl -f http://localhost:8080/actuator/health || exit 1" \ + --health-cmd="wget -q -O /dev/null http://localhost:8080/actuator/health || exit 1" \ --health-interval=30s \ --health-timeout=10s \ --health-retries=3 \ @@ -70,3 +72,4 @@ jobs: -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ -e JWT_EXPIRATION="${{ secrets.JWT_EXPIRATION }}" \ ${{ secrets.DOCKERHUB_USERNAME }}/piroin-backend:latest + docker image prune -a -f diff --git a/frontend/src/pages/pirocheck/students/StudentList.module.css b/frontend/src/pages/pirocheck/students/StudentList.module.css index 4c17539..d00d43b 100644 --- a/frontend/src/pages/pirocheck/students/StudentList.module.css +++ b/frontend/src/pages/pirocheck/students/StudentList.module.css @@ -21,7 +21,6 @@ align-items: center; background: var(--gray600); border-radius: 10px; - overflow: hidden; width: 480px; max-width: calc(100vw - 56px); margin-bottom: 30px; @@ -31,6 +30,7 @@ .searchInput { flex: 1; + min-width: 0; background: transparent; border: none; outline: none; diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index f2b3a9f..bef7cfb 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -92,6 +92,7 @@ function QnADetailPage() { const [selectedImages, setSelectedImages] = useState([]); // 여러 장 const [imagePreviews, setImagePreviews] = useState([]); // 여러 장 미리보기 const fileInputRef = useRef(null); + const commentTextareaRef = useRef(null); // ── 댓글 수정 상태 ─────────────────────────────── const [commentMenuId, setCommentMenuId] = useState(null); @@ -420,6 +421,7 @@ function QnADetailPage() { setImagePreviews([]); // 로컬 상태에 blob URL을 직접 넣으면 새로고침 시 이미지가 깨지므로 // 등록 직후 fetchQuestion으로 서버의 정식 URL을 받아온다. + if (commentTextareaRef.current) commentTextareaRef.current.style.height = 'auto'; await fetchQuestion(); } } catch (err) { @@ -709,14 +711,25 @@ function QnADetailPage() { style={{ display: 'none' }} onChange={handleImageSelect} /> - setCommentText(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) handleCommentSubmit(); }} + onChange={e => { + setCommentText(e.target.value); + e.target.style.height = 'auto'; + e.target.style.height = `${Math.min(e.target.scrollHeight, 150)}px`; + }} + onKeyDown={e => { + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + handleCommentSubmit(); + } + }} onPaste={handlePaste} + rows={1} disabled={isSubmitting} /> { return hasUpdatedQuestion ? regroupQuestions(updatedQuestions) : groups; }; -const addQuestionToGroups = (groups, question) => { - if (!question?.questionId) return groups; - - const existingQuestions = [ - ...groups.popularQuestions, - ...groups.unresolvedQuestions, - ...groups.resolvedQuestions, - ]; - const alreadyExists = existingQuestions.some(item => item.questionId === question.questionId); - - if (alreadyExists) return groups; - return regroupQuestions([question, ...existingQuestions]); -}; - const buildUnderstandingCheckFromEvent = (eventData) => ({ checkId: eventData.checkId, content: eventData.content, @@ -199,6 +185,7 @@ function QnAListPage() { }); // ── 필터 / 정렬 상태 ───────────────────────────── + const [questionScope, setQuestionScope] = useState('all'); const [filterCurious, setFilterCurious] = useState(false); const [filterUnsolved, setFilterUnsolved] = useState(false); const [sortOrder, setSortOrder] = useState('정렬'); @@ -212,6 +199,7 @@ function QnAListPage() { // 질문별 댓글 이미지 미리보기: { [questionId]: string[] } const [commentImagePreviews, setCommentImagePreviews] = useState({}); const commentFileRefs = useRef({}); + const commentTextareaRefs = useRef({}); // ── 새 질문 / 이해도 입력 상태 ────────────────── const [newQuestion, setNewQuestion] = useState(''); @@ -221,6 +209,7 @@ function QnAListPage() { const [selectedImages, setSelectedImages] = useState([]); const [imagePreviews, setImagePreviews] = useState([]); const fileInputRef = useRef(null); + const newQuestionTextareaRef = useRef(null); const applyQuestionGroups = useCallback((groups) => { questionGroupsRef.current = groups; @@ -311,47 +300,13 @@ function QnAListPage() { applyQuestionGroups(nextGroups); }, [applyQuestionGroups]); - const buildQuestionFromCreatedEvent = useCallback(async (eventData) => { - if (!eventData?.questionId) return 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; - } - }) - ); + const handleQuestionCreatedEvent = useCallback((eventData) => { + if (!eventData?.questionId) return; - return { - questionId: eventData.questionId, - content: eventData.content, - imageUrls: blobUrls.filter(Boolean), - isResolved: false, - isPopular: false, - isLiked: false, - isMine: false, - isNew: eventData.isNew ?? true, - iLiked: false, - likeCount: eventData.likeCount ?? 0, - commentCount: eventData.commentCount ?? 0, - previewComments: [], - createdAt: eventData.createdAt, - }; - }, []); - - const handleQuestionCreatedEvent = useCallback(async (eventData) => { - const createdQuestion = await buildQuestionFromCreatedEvent(eventData); - if (!createdQuestion) return; - - const nextGroups = addQuestionToGroups(questionGroupsRef.current, createdQuestion); - applyQuestionGroups(nextGroups); - }, [applyQuestionGroups, buildQuestionFromCreatedEvent]); + // 생성 이벤트에는 로그인 사용자 기준 isMine 값이 없으므로, + // 목록을 다시 조회해 "내 질문" 필터에서도 정확히 분류되도록 한다. + void fetchQuestions(understandingIndex); + }, [fetchQuestions, understandingIndex]); const handleQuestionUpdatedEvent = useCallback((eventData) => { const nextGroups = updateQuestionGroupsByQuestionEvent(questionGroupsRef.current, eventData); @@ -566,6 +521,8 @@ function QnAListPage() { setCommentImages(prev => ({ ...prev, [questionId]: [] })); setCommentImagePreviews(prev => ({ ...prev, [questionId]: [] })); setCommentOpenId(null); + const textareaEl = commentTextareaRefs.current[questionId]; + if (textareaEl) textareaEl.style.height = 'auto'; } } catch (err) { console.error('댓글 등록 실패:', err); @@ -662,6 +619,7 @@ function QnAListPage() { setSelectedImages([]); setImagePreviews([]); fetchQuestions(understandingIndex); + if (newQuestionTextareaRef.current) newQuestionTextareaRef.current.style.height = 'auto'; } } catch (err) { console.error('질문 등록 실패:', err); @@ -688,6 +646,7 @@ function QnAListPage() { setNewQuestion(''); setUnderstandingIndex(0); fetchQuestions(0); + if (newQuestionTextareaRef.current) newQuestionTextareaRef.current.style.height = 'auto'; } } catch (err) { console.error('이해도 등록 실패:', err); @@ -706,8 +665,9 @@ function QnAListPage() { const displayedQuestions = (() => { let list = allQuestions; - if (isStaff && filterUnsolved) list = unresolvedQuestions; - if (!isStaff && filterCurious) list = allQuestions.filter(q => q.iLiked); + if (questionScope === 'mine') list = list.filter(q => q.isMine); + if (isStaff && filterUnsolved) list = list.filter(q => !q.isResolved); + if (!isStaff && filterCurious) list = list.filter(q => q.iLiked); if (sortOrder === '최신순') { list = [...list].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); @@ -730,35 +690,56 @@ function QnAListPage() { {/* ── 필터 / 정렬 행 ── */} - {isStaff ? ( - - setFilterUnsolved(e.target.checked)} - className={styles.curiousCheckbox} /> - 미해결 질문 - - ) : ( - - setFilterCurious(e.target.checked)} - className={styles.curiousCheckbox} /> - 저도 궁금해요 - - )} - - setShowSortMenu(prev => !prev)}> - {sortOrder} + + setQuestionScope('all')} + > + 전체 질문 - {showSortMenu && ( - - {['기본', '최신순', '저도궁금해요순'].map(option => ( - { setSortOrder(option); setShowSortMenu(false); }}> - {option} - - ))} - + setQuestionScope('mine')} + > + 내 질문 + + + + + {isStaff ? ( + + setFilterUnsolved(e.target.checked)} + className={styles.curiousCheckbox} /> + 미해결 질문 + + ) : ( + + setFilterCurious(e.target.checked)} + className={styles.curiousCheckbox} /> + 저도 궁금해요 + )} + + setShowSortMenu(prev => !prev)}> + {sortOrder} + + {showSortMenu && ( + + {['기본', '최신순', '저도궁금해요순'].map(option => ( + { setSortOrder(option); setShowSortMenu(false); }}> + {option} + + ))} + + )} + @@ -802,6 +783,15 @@ function QnAListPage() { {/* ── 질문 목록 ── */} + {displayedQuestions.length === 0 && ( + + {questionScope === 'mine' + ? '작성한 질문이 없습니다.' + : (filterCurious || filterUnsolved) + ? '조건에 맞는 질문이 없습니다.' + : '등록된 질문이 없습니다.'} + + )} {displayedQuestions.map(question => ( - {comment.content} + + {comment.content} {comment.hasImage && ( + - { commentTextareaRefs.current[question.questionId] = el; }} className={styles.commentInput} placeholder="댓글을 입력해주세요..." value={commentInputs[question.questionId] || ''} - onChange={e => handleCommentChange(question.questionId, e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) handleCommentSubmit(e, question.questionId); }} + onChange={e => { + handleCommentChange(question.questionId, e.target.value); + e.target.style.height = 'auto'; + e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`; + }} + onKeyDown={e => { + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + handleCommentSubmit(e, question.questionId); + } + }} onPaste={e => handleCommentPaste(e, question.questionId)} + rows={1} autoFocus /> > )} - setNewQuestion(e.target.value)} + onChange={e => { + setNewQuestion(e.target.value); + e.target.style.height = 'auto'; + e.target.style.height = `${Math.min(e.target.scrollHeight, 150)}px`; + }} onKeyDown={e => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) isStaff ? handleNewUnderstandCheck() : handleNewQuestion(); + if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + isStaff ? handleNewUnderstandCheck() : handleNewQuestion(); + } }} onPaste={handleNewQuestionPaste} + rows={1} disabled={isSubmitting} />