Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
e98ab48
Merge branch 'Feat/#319/lecture-live-api' of https://github.com/KW-Cl…
iinuyha Oct 5, 2025
eb714c3
✨ (#332) 강의 질문 목록의 소켓 로직을 useLectureChat 훅으로 통합하고 메시지 전송·표시 로직 및 UI를 개선
iinuyha Oct 5, 2025
9265f38
Merge branch 'Feat/#315/save-question' of https://github.com/KW-Class…
iinuyha Oct 5, 2025
582b6e3
✨ (#332) 강의 채팅 목록을 가져오는 API 함수 추가 및 관련 타입 정의, 엔드포인트 상수 업데이트
iinuyha Oct 5, 2025
fc978db
✨ (#332) 강의 질문 목록 섹션에 과거 메시지를 불러오는 기능 추가 및 메시지 표시 로직 개선
iinuyha Oct 5, 2025
7ed4d66
🔧 (#332) useAuthStore에서 refresh_token 쿠키 삭제 로직 추가
iinuyha Oct 6, 2025
c3820f1
🔧 (#332) useAuthStore에서 refresh_token 쿠키 삭제 로직 제거
iinuyha Oct 6, 2025
db8186c
✨ (#332) useAuthStore에서 로그아웃 시 선택된 클래스 스토어 초기화 로직 추가
iinuyha Oct 6, 2025
e847a36
🔧 (#332) 클래스 ID 상태 관리 로직을 useSelectedClassStore 사용으로 변경
iinuyha Oct 6, 2025
576cdbf
Merge branch 'Feat/#314/before-chatting' of https://github.com/KW-Cla…
iinuyha Oct 6, 2025
fd1ef1a
Merge branch 'Feat/#315/save-question' of https://github.com/KW-Class…
iinuyha Oct 6, 2025
d181c2d
✨ (#332) 강의 질문 목록 섹션의 마지막 질문에 여백 추가
iinuyha Oct 6, 2025
beb0b6a
✨ (#332) 항상 최신 채팅이 보이도록 스크롤 추가
iinuyha Oct 6, 2025
79beb20
Merge branch 'dev' into Feat/#332/student-chatting-socket
iinuyha Oct 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/api/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const axiosInstance = axios.create({
withCredentials: true,
});

// 토큰이 필요하지 않은 API들 (로그인, 로그아웃, 회원가입, 이메일 인증)
// 토큰이 필요하지 않은 API들 (로그인, 회원가입, 이메일 인증 페이지)
const noTokenRequired = [
"/users/login",
"/users/signup",
Expand Down
19 changes: 19 additions & 0 deletions frontend/api/lectures/fetchChattingList.ts
Original file line number Diff line number Diff line change
@@ -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<FetchChattingListResult>
>(ENDPOINTS.LECTURES.GET_CHATTING_LIST(lectureId));
return response.data;
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response) {
return error.response.data as ApiResponse<FetchChattingListResult>;
}
throw error;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,116 +1,79 @@
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,
}: {
lectureId: string;
}) {
const { lectureStatus } = useLectureStatusStore();
const [questions, setQuestions] = useState<string[]>([]);
const { messages, connected, sendMessage } = useLectureChat(lectureId);
const [questionInput, setQuestionInput] = useState<string>("");
const [loading, setLoading] = useState(true);
const socketRef = useRef<WebSocket | null>(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<ChatMessage[]>([]);
const listEndRef = useRef<HTMLLIElement | null>(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}`;
Expand All @@ -126,12 +89,22 @@ export default function QuestionListSection({
{lectureStatus === "onLecture" ? (
<div className={styles.questionListContainer}>
<ul className={styles.questionList}>
{questions.map((q, index) => (
{combinedMessages.map((message, index) => (
<li key={index} className={styles.questionItem}>
<div className={styles.message}>{q}</div>
<div className={styles.timestamp}>{now()}</div>
<div className={styles.message}>
<div className={styles.content}>{message.content}</div>
</div>
<div className={styles.timestamp}>
{formatTime(message.timestamp)}
</div>
{message.role === "TEACHER" && (
<div className={styles.teacherName}>
* 강사가 보낸 메시지입니다.
</div>
)}
</li>
))}
<li ref={listEndRef} className={styles.bottomSpacer} />
</ul>
<div className={styles.questionInputContainer}>
<BasicInput
Expand All @@ -143,6 +116,7 @@ export default function QuestionListSection({
icon={<Send />}
onClick={sendQuestion}
ariaLabel={"전송"}
disabled={!connected}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,29 +10,20 @@ 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 }>();
const docBtnRef = useRef<HTMLSpanElement>(null);

const [openDoc, setOpenDoc] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const [classId, setClassId] = useState<string | null>(null);
const [lectureNotes, setLectureNotes] = useState<FetchLectureNoteByLectureIdResult[]>([]);
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) {
Expand All @@ -45,11 +36,11 @@ export default function LectureNoteButton() {
console.error("강의자료 조회 오류:", err);
setLectureNotes([]);
}
};
}, [lectureId]);

useEffect(() => {
if (lectureId) fetchLectureNotes();
}, [lectureId]);
}, [lectureId, fetchLectureNotes]);

return (
<>
Expand All @@ -69,15 +60,15 @@ export default function LectureNoteButton() {
side="bottom"
>
<LectureNotePopover
notes={lectureNotes}
onPicked={() => setOpenDoc(false)}
onUploadRequest={() => setUploadOpen(true)}
notes={lectureNotes}
onPicked={() => setOpenDoc(false)}
onUploadRequest={() => setUploadOpen(true)}
/>
</ToolPopover>

{uploadOpen && classId && (
{uploadOpen && selectedClassId && (
<FileSelectModal
classId={classId}
classId={selectedClassId}
lectureId={lectureId}
onClose={() => setUploadOpen(false)}
registeredFiles={lectureNotes.map((n) => n.lectureNoteName)}
Expand All @@ -89,4 +80,4 @@ export default function LectureNoteButton() {
)}
</>
);
}
}
2 changes: 2 additions & 0 deletions frontend/constants/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
33 changes: 0 additions & 33 deletions frontend/store/resetAllStores.ts

This file was deleted.

Loading