diff --git a/frontend/api/axiosInstance.ts b/frontend/api/axiosInstance.ts index 2514ad49..89b3c2db 100644 --- a/frontend/api/axiosInstance.ts +++ b/frontend/api/axiosInstance.ts @@ -7,7 +7,7 @@ export const axiosInstance = axios.create({ withCredentials: true, }); -// 토큰이 필요하지 않은 API들 (로그인, 로그아웃, 회원가입, 이메일 인증) +// 토큰이 필요하지 않은 API들 (로그인, 회원가입, 이메일 인증 페이지) const noTokenRequired = [ "/users/login", "/users/signup", diff --git a/frontend/api/lectures/fetchChattingList.ts b/frontend/api/lectures/fetchChattingList.ts new file mode 100644 index 00000000..8e1e1159 --- /dev/null +++ b/frontend/api/lectures/fetchChattingList.ts @@ -0,0 +1,19 @@ +import { axiosInstance } from "@/api/axiosInstance"; +import axios from "axios"; // 추가 +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { FetchChattingListResult } from "@/types/lectures/fetchChattingListTypes"; + +export async function fetchChattingList(lectureId: string) { + try { + const response = await axiosInstance.get< + ApiResponse + >(ENDPOINTS.LECTURES.GET_CHATTING_LIST(lectureId)); + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response) { + return error.response.data as ApiResponse; + } + throw error; + } +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.module.scss b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.module.scss index c16fec61..f70ac5ca 100644 --- a/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.module.scss +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.module.scss @@ -22,6 +22,9 @@ } .questionItem { + display: flex; + flex-direction: column; + gap: $spacing-xs; width: fit-content; padding: $spacing-sm; border: 1px solid $color-neutral-7; @@ -30,11 +33,15 @@ &:last-child { border-bottom: none; + margin-bottom: $spacing-sm; } } +.bottomSpacer { + height: 1px; +} + .timestamp { - margin-top: $spacing-xs; font-size: $font-size-sm; color: $color-neutral-5; } @@ -46,6 +53,12 @@ font-weight: $font-weight-light; } +.teacherName { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $color-blue; +} + .questionInputContainer { display: flex; gap: $spacing-sm; diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx index c8c5c684..193d1329 100644 --- a/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx @@ -1,11 +1,13 @@ -import React, { useEffect, useState, useRef, useCallback } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { useLectureStatusStore } from "@/store/useLectureStatusStore"; +import { ChatMessage, useLectureChat } from "@/hooks/useLectureChat"; import NoDataView from "@/components/NoDataView/NoDataView"; import { MessageCircle, Send } from "lucide-react"; import styles from "./QuestionListSection.module.scss"; import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner"; import BasicInput from "@/components/Input/BasicInput/BasicInput"; import IconButton from "@/components/Button/IconButton/IconButton"; +import { fetchChattingList } from "@/api/lectures/fetchChattingList"; export default function QuestionListSection({ lectureId, @@ -13,104 +15,65 @@ export default function QuestionListSection({ lectureId: string; }) { const { lectureStatus } = useLectureStatusStore(); - const [questions, setQuestions] = useState([]); + const { messages, connected, sendMessage } = useLectureChat(lectureId); const [questionInput, setQuestionInput] = useState(""); const [loading, setLoading] = useState(true); - const socketRef = useRef(null); - - // 소켓 연결 함수 - const connectSocket = useCallback(() => { - if (socketRef.current?.readyState === WebSocket.OPEN) return; - - try { - // TODO: 실제 소켓 서버 URL로 변경 - const socketUrl = `ws://localhost:8080/ws/lecture/${lectureId}`; - socketRef.current = new WebSocket(socketUrl); - - socketRef.current.onopen = () => { - console.log("소켓 연결 성공"); - }; - - socketRef.current.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - handleSocketMessage(data); - } catch (error) { - console.error("소켓 메시지 파싱 오류:", error); - } - }; - - socketRef.current.onclose = () => { - console.log("소켓 연결 종료"); - }; - - socketRef.current.onerror = (error) => { - console.error("소켓 오류:", error); - }; - } catch (error) { - console.error("소켓 연결 실패:", error); - } - }, [lectureId]); - - // 소켓 메시지 처리 함수 - const handleSocketMessage = (data: { - type: string; - question?: string; - questions?: string[]; - }) => { - switch (data.type) { - case "newQuestion": - setQuestions((prev) => [...prev, data.question || ""]); - break; - case "questionList": - setQuestions(data.questions || []); - break; - default: - console.log("알 수 없는 메시지 타입:", data.type); - } - }; + const [previousMessages, setPreviousMessages] = useState([]); + const listEndRef = useRef(null); // 질문 전송 함수 const sendQuestion = () => { - if (!questionInput.trim() || !socketRef.current) return; + if (!questionInput.trim() || !connected) return; - const message = { - type: "sendQuestion", - lectureId: lectureId, - question: questionInput.trim(), - timestamp: new Date().toISOString(), - }; - - socketRef.current.send(JSON.stringify(message)); + sendMessage(questionInput.trim()); setQuestionInput(""); // 입력창 초기화 }; useEffect(() => { - // TODO: API 호출로 변경 - setQuestions([ - "dd", - "AsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfas", - "Asdfadfg", - "Asdfadfg", - ]); - setLoading(false); - - // 강의 중일 때만 소켓 연결 - if (lectureStatus === "onLecture") { - connectSocket(); - } - - // 컴포넌트 언마운트 시 소켓 연결 해제 - return () => { - if (socketRef.current) { - socketRef.current.close(); + let isMounted = true; + const loadPreviousMessages = async () => { + try { + const res = await fetchChattingList(lectureId); + if (!isMounted) return; + if (res.isSuccess && Array.isArray(res.result)) { + const mapped: ChatMessage[] = res.result.map((m) => ({ + senderId: null, + senderName: null, + content: m.content, + role: m.role, + timestamp: m.timestamp, + })); + setPreviousMessages(mapped); + } else { + setPreviousMessages([]); + } + } catch { + if (!isMounted) return; + setPreviousMessages([]); + } finally { + if (isMounted) setLoading(false); } }; - }, [lectureId, lectureStatus, connectSocket]); + loadPreviousMessages(); + return () => { + isMounted = false; + }; + }, [lectureId]); + + const combinedMessages = useMemo(() => { + // 과거 메시지 이후에 실시간 메시지 순서로 노출 + return [...previousMessages, ...messages]; + }, [previousMessages, messages]); + + // 새로운 메시지가 추가될 때 항상 맨 아래로 스크롤 + useEffect(() => { + listEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [combinedMessages]); - const now = () => { + // 시간 포맷팅 함수 + const formatTime = (timestamp: string) => { try { - const date = new Date(); + const date = new Date(timestamp); const hours = date.getHours().toString().padStart(2, "0"); const minutes = date.getMinutes().toString().padStart(2, "0"); return `${hours}:${minutes}`; @@ -126,12 +89,22 @@ export default function QuestionListSection({ {lectureStatus === "onLecture" ? (
    - {questions.map((q, index) => ( + {combinedMessages.map((message, index) => (
  • -
    {q}
    -
    {now()}
    +
    +
    {message.content}
    +
    +
    + {formatTime(message.timestamp)} +
    + {message.role === "TEACHER" && ( +
    + * 강사가 보낸 메시지입니다. +
    + )}
  • ))} +
} onClick={sendQuestion} ariaLabel={"전송"} + disabled={!connected} />
diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/LectureNoteButton/LectureNoteButton.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/LectureNoteButton/LectureNoteButton.tsx index a920027f..ccdc2fc6 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/LectureNoteButton/LectureNoteButton.tsx +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureNote/LectureNoteButton/LectureNoteButton.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "next/navigation"; import IconButton from "@/components/Button/IconButton/IconButton"; import ToolPopover from "../../ToolPopover/ToolPopover"; @@ -10,6 +10,7 @@ import { FileText } from "lucide-react"; import { fetchLectureNoteByLectureId } from "@/api/lectures/fetchLectureNoteByLectureId"; import { FetchLectureNoteByLectureIdResult } from "@/types/lectures/fetchLectureNoteByLectureIdTypes"; import styles from "./LectureNoteButton.module.scss"; +import useSelectedClassStore from "@/store/useSelectedClassStore"; export default function LectureNoteButton() { const { lectureId } = useParams<{ lectureId: string }>(); @@ -17,22 +18,12 @@ export default function LectureNoteButton() { const [openDoc, setOpenDoc] = useState(false); const [uploadOpen, setUploadOpen] = useState(false); - const [classId, setClassId] = useState(null); - const [lectureNotes, setLectureNotes] = useState([]); + const [lectureNotes, setLectureNotes] = useState< + FetchLectureNoteByLectureIdResult[] + >([]); + const { selectedClassId } = useSelectedClassStore(); - useEffect(() => { - const raw = localStorage.getItem("class-storage"); - if (raw) { - try { - const parsed = JSON.parse(raw); - setClassId(parsed.state?.selectedClassId ?? null); - } catch (err) { - console.error("class-storage 파싱 실패:", err); - } - } - }, []); - - const fetchLectureNotes = async () => { + const fetchLectureNotes = useCallback(async () => { try { const response = await fetchLectureNoteByLectureId(lectureId); if (response.isSuccess && response.result) { @@ -45,11 +36,11 @@ export default function LectureNoteButton() { console.error("강의자료 조회 오류:", err); setLectureNotes([]); } - }; + }, [lectureId]); useEffect(() => { if (lectureId) fetchLectureNotes(); - }, [lectureId]); + }, [lectureId, fetchLectureNotes]); return ( <> @@ -69,15 +60,15 @@ export default function LectureNoteButton() { side="bottom" > setOpenDoc(false)} - onUploadRequest={() => setUploadOpen(true)} + notes={lectureNotes} + onPicked={() => setOpenDoc(false)} + onUploadRequest={() => setUploadOpen(true)} /> - {uploadOpen && classId && ( + {uploadOpen && selectedClassId && ( setUploadOpen(false)} registeredFiles={lectureNotes.map((n) => n.lectureNoteName)} @@ -89,4 +80,4 @@ export default function LectureNoteButton() { )} ); -} \ No newline at end of file +} diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index 18ae98e7..a21129e7 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -72,6 +72,8 @@ export const ENDPOINTS = { `${BASE_API}/lectures/student/${lectureId}`, GET_CLASS_NAME: (lectureId: string) => `${BASE_API}/lectures/classes/${lectureId}`, + GET_CHATTING_LIST: (lectureId: string) => + `${BASE_API}/lectures/chatting/before/${lectureId}`, // 노트 관련 UPLOAD_NOTE: (classId: string) => diff --git a/frontend/store/resetAllStores.ts b/frontend/store/resetAllStores.ts deleted file mode 100644 index 8f052884..00000000 --- a/frontend/store/resetAllStores.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useAuthStore } from "./useAuthStore"; -import { useQuizStore } from "./useQuizStore"; -import useLectureListStore from "./useLectureListStore"; -import useSelectedClassStore from "./useSelectedClassStore"; -import useClassListStore from "./useClassListStore"; -import { useSignupStore } from "./useSignupStore"; -import { useLectureStatusStore } from "./useLectureStatusStore"; - -/** - * 모든 스토어를 초기 상태로 리셋하는 함수 - * 로그아웃이나 토큰 만료 시 사용 - */ -export const resetAllStores = () => { - // 각 스토어의 reset 함수 호출 - // auth store는 직접 초기화 (무한 루프 방지) - useAuthStore.setState({ - accessToken: null, - userId: null, - role: null, - iat: null, - exp: null, - }); - localStorage.removeItem("accessToken"); - - useQuizStore.getState().reset(); - useLectureListStore.getState().reset(); - useSelectedClassStore.getState().reset(); - useClassListStore.getState().reset(); - useSignupStore.getState().reset(); - useLectureStatusStore.getState().clearLectureStatus(); - - console.log("모든 스토어가 초기화되었습니다."); -}; diff --git a/frontend/store/useAuthStore.ts b/frontend/store/useAuthStore.ts index cab5c9ad..816ffe9c 100644 --- a/frontend/store/useAuthStore.ts +++ b/frontend/store/useAuthStore.ts @@ -6,6 +6,8 @@ import useLectureListStore from "./useLectureListStore"; import useSelectedClassStore from "./useSelectedClassStore"; import useClassListStore from "./useClassListStore"; import { useSignupStore } from "./useSignupStore"; +import { useLectureStatusStore } from "./useLectureStatusStore"; +import { useClassTitleStore } from "./useClassTitleStore"; interface AuthState { accessToken: string | null; @@ -37,6 +39,7 @@ function getInitialAuthState() { if (decodedToken.exp < currentTime) { // 토큰이 만료되었으면 localStorage에서 제거하고 초기 상태 반환 localStorage.removeItem("accessToken"); + return { accessToken: null, userId: null, @@ -56,6 +59,7 @@ function getInitialAuthState() { } catch { // 토큰 파싱 실패 시 localStorage에서 제거하고 초기 상태 반환 localStorage.removeItem("accessToken"); + return { accessToken: null, userId: null, @@ -131,6 +135,7 @@ export const useAuthStore = create((set, get) => ({ logout: () => { set({ accessToken: null, userId: null, role: null, iat: null, exp: null }); localStorage.removeItem("accessToken"); + if (refreshTimeout) clearTimeout(refreshTimeout); if (expirationCheckInterval) clearInterval(expirationCheckInterval); @@ -140,6 +145,9 @@ export const useAuthStore = create((set, get) => ({ useSelectedClassStore.getState().reset(); useClassListStore.getState().reset(); useSignupStore.getState().reset(); + useLectureStatusStore.getState().clearLectureStatus(); + useClassTitleStore.getState().clearClassTitle(); + useSelectedClassStore.getState().reset(); console.log("로그아웃: 모든 스토어가 초기화되었습니다."); }, diff --git a/frontend/types/lectures/fetchChattingListTypes.ts b/frontend/types/lectures/fetchChattingListTypes.ts new file mode 100644 index 00000000..f11a3ed4 --- /dev/null +++ b/frontend/types/lectures/fetchChattingListTypes.ts @@ -0,0 +1,5 @@ +export type FetchChattingListResult = { + content: string; + timestamp: string; + role: string; +}[];