diff --git a/backend/src/main/java/org/example/backend/domain/quiz/converter/QuizConverter.java b/backend/src/main/java/org/example/backend/domain/quiz/converter/QuizConverter.java index 7d89b7ca..c574a0af 100644 --- a/backend/src/main/java/org/example/backend/domain/quiz/converter/QuizConverter.java +++ b/backend/src/main/java/org/example/backend/domain/quiz/converter/QuizConverter.java @@ -20,27 +20,31 @@ public class QuizConverter { private final OptionRepository optionRepository; public QuizListResponseDTO toQuizListResponseDTO(UUID lectureId, List quizList) { - List quizDTOs = quizList.stream().map(quiz -> { - List options = new ArrayList<>(); - if (quiz.getType() == QuizType.MULTIPLE_CHOICE) { - options = optionRepository.findByQuizId(quiz.getId()) - .stream() - .map(option -> new OptionResponseDTO( - option.getId(), - option.getOptionOrder(), - option.getText() - )) - .toList(); - } - return new QuizListResponseDTO.QuizDTO( - quiz.getId(), - quiz.getQuizOrder(), - quiz.getQuiz(), - quiz.getSolution(), - QuizResultStudentConverter.toCamelCase(quiz.getType()), - options - ); - }).toList(); + List quizDTOs = quizList.stream() + .sorted((q1, q2) -> Integer.compare(q1.getQuizOrder(), q2.getQuizOrder())) + .map(quiz -> { + List options = new ArrayList<>(); + if (quiz.getType() == QuizType.MULTIPLE_CHOICE) { + options = optionRepository.findByQuizId(quiz.getId()) + .stream() + .map(option -> new OptionResponseDTO( + option.getId(), + option.getOptionOrder(), + option.getText() + )) + .sorted((o1, o2) -> Integer.compare(o1.getOptionOrder(), o2.getOptionOrder())) + .toList(); + } + return new QuizListResponseDTO.QuizDTO( + quiz.getId(), + quiz.getQuizOrder(), + quiz.getQuiz(), + quiz.getSolution(), + QuizResultStudentConverter.toCamelCase(quiz.getType()), + options + ); + }) + .toList(); return new QuizListResponseDTO(lectureId, quizDTOs); } 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/classes/fetchClassNameByLectureId.ts b/frontend/api/classes/fetchClassNameByLectureId.ts new file mode 100644 index 00000000..d40dfc5e --- /dev/null +++ b/frontend/api/classes/fetchClassNameByLectureId.ts @@ -0,0 +1,20 @@ +import { axiosInstance } from "@/api/axiosInstance"; +import axios from "axios"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { FetchClassNameByLectureIdResult } from "@/types/classes/fetchClassNameByLectureIdTypes"; + +export async function fetchClassNameByLectureId(lectureId: string) { + try { + const response = await axiosInstance.get< + ApiResponse + >(ENDPOINTS.LECTURES.GET_CLASS_NAME(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/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/api/lectures/fetchStudentLectureDetail.ts b/frontend/api/lectures/fetchStudentLectureDetail.ts new file mode 100644 index 00000000..50c2a759 --- /dev/null +++ b/frontend/api/lectures/fetchStudentLectureDetail.ts @@ -0,0 +1,20 @@ +import { axiosInstance } from "@/api/axiosInstance"; +import axios from "axios"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { FetchStudentLectureDetailResult } from "@/types/lectures/fetchStudentLectureDetailTypes"; + +export async function fetchStudentLectureDetail(lectureId: string) { + try { + const response = await axiosInstance.get< + ApiResponse + >(ENDPOINTS.LECTURES.GET_STUDENT_LECTURE_DETAIL(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/api/lectures/saveChatting.ts b/frontend/api/lectures/saveChatting.ts new file mode 100644 index 00000000..9e141c0f --- /dev/null +++ b/frontend/api/lectures/saveChatting.ts @@ -0,0 +1,19 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; + +export async function saveChatting( lectureId: string ) { + try { + const response = await axiosInstance.post>( + ENDPOINTS.LECTURES.SAVE_CHAT(lectureId) + ); + + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response) { + return error.response.data as ApiResponse; + } + throw error; + } +} \ No newline at end of file diff --git a/frontend/api/quizzes/fetchQuizList.ts b/frontend/api/quizzes/fetchQuizList.ts new file mode 100644 index 00000000..427daa6c --- /dev/null +++ b/frontend/api/quizzes/fetchQuizList.ts @@ -0,0 +1,19 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { fetchQuizListResult } from "@/types/quizzes/fetchQuizListTypes"; + +export async function fetchQuizList(lectureId: string) { + try { + const response = await axiosInstance.get>( + ENDPOINTS.QUIZZES.GET(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/api/quizzes/getMyQuizResult.ts b/frontend/api/quizzes/getMyQuizResult.ts new file mode 100644 index 00000000..6a35ed46 --- /dev/null +++ b/frontend/api/quizzes/getMyQuizResult.ts @@ -0,0 +1,19 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { getMyQuizResultResult } from "@/types/quizzes/getMyQuizResultTypes"; + +export async function getMyQuizResult(lectureId: string) { + try { + const response = await axiosInstance.get< + ApiResponse + >(ENDPOINTS.QUIZZES.GET_RESULT(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/api/quizzes/submitQuiz.ts b/frontend/api/quizzes/submitQuiz.ts new file mode 100644 index 00000000..2d8122a6 --- /dev/null +++ b/frontend/api/quizzes/submitQuiz.ts @@ -0,0 +1,23 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { + SubmitQuizRequest, + SubmitQuizResult, +} from "@/types/quizzes/submitQuizTypes"; + +export async function submitQuiz(data: SubmitQuizRequest) { + try { + const response = await axiosInstance.post< + ApiResponse + >(ENDPOINTS.QUIZZES.SUBMIT, data); + + 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/class-detail/[classId]/_components/LectureNote/LectureNote.module.scss b/frontend/app/student/class-detail/[classId]/_components/LectureNote/LectureNote.module.scss index 53ba5fa5..1f65501e 100644 --- a/frontend/app/student/class-detail/[classId]/_components/LectureNote/LectureNote.module.scss +++ b/frontend/app/student/class-detail/[classId]/_components/LectureNote/LectureNote.module.scss @@ -24,11 +24,7 @@ border: 1px solid $color-neutral-7; border-radius: $radius-md; background-color: $color-white; - cursor: pointer; transition: background-color 0.3s ease; - &:hover { - background-color: $color-skyblue; - } } .fileItem > :first-child { diff --git a/frontend/app/student/class-detail/[classId]/_components/LectureNote/LectureNote.tsx b/frontend/app/student/class-detail/[classId]/_components/LectureNote/LectureNote.tsx index dce67792..d0e61a0f 100644 --- a/frontend/app/student/class-detail/[classId]/_components/LectureNote/LectureNote.tsx +++ b/frontend/app/student/class-detail/[classId]/_components/LectureNote/LectureNote.tsx @@ -4,6 +4,10 @@ import { useParams } from "next/navigation"; import { fetchLectureNotesByClass } from "@/api/lectures/fetchLectureNotesByClass"; import FileDisplay from "@/components/FileDisplay/FileDisplay"; import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner"; +import IconButton from "@/components/Button/IconButton/IconButton"; +import { Download } from "lucide-react"; +import { FetchLectureNoteByLectureIdResult } from "@/types/lectures/fetchLectureNoteByLectureIdTypes"; +import { downloadFileWithErrorHandling } from "@/utils/downloadUtils"; interface LectureNote { lectureNoteId: string; @@ -44,8 +48,11 @@ export default function LectureNote() { ); } - const handleNoteClick = (lectureNoteUrl: string) => { - window.open(lectureNoteUrl, "_blank"); + const handleDownload = async (note: FetchLectureNoteByLectureIdResult) => { + await downloadFileWithErrorHandling( + note.lectureNoteUrl, + note.lectureNoteName || "강의자료" + ); }; return ( @@ -53,15 +60,16 @@ export default function LectureNote() { {lectureNotes.length > 0 ? (
{lectureNotes.map((note) => ( -
handleNoteClick(note.lectureNoteUrl)} - > - -
- {note.fileSize} -
+
+ + } + onClick={() => handleDownload(note)} + ariaLabel={"강의자료 다운로드"} + />
))}
diff --git a/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx b/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx index e7d198b2..c82e4e2a 100644 --- a/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx +++ b/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx @@ -86,7 +86,9 @@ export default function ClassListSection() {
{classItem.className} - 박재성 + + {classItem.professorName} +
@@ -94,12 +96,14 @@ export default function ClassListSection() {
- 월 (10:15~11:45)/수 (12:00~13:15) + {classItem.classDate}
- 2024.03.04 ~ 2025.06.13 + + {classItem.startDate} ~ {classItem.endDate} +
diff --git a/frontend/app/student/layout.tsx b/frontend/app/student/layout.tsx index 79678209..603d518b 100644 --- a/frontend/app/student/layout.tsx +++ b/frontend/app/student/layout.tsx @@ -9,6 +9,7 @@ import BackWithTitleHeader from "@/components/Header/Student/BackWithTitleHeader import TitleHeader from "@/components/Header/Student/TitleHeader/TitleHeader"; import Navigation from "@/components/Navigation/Navigation"; +import { useClassTitleStore } from "@/store/useClassTitleStore"; export default function StudentLayout({ children, @@ -16,6 +17,7 @@ export default function StudentLayout({ children: React.ReactNode; }) { const pathname = usePathname(); + const { classTitle } = useClassTitleStore(); // 현재 경로에 해당하는 라우트 설정 찾기 const currentRoute = Object.values(STUDENT_ROUTE_CONFIG).find((config) => { @@ -38,7 +40,9 @@ export default function StudentLayout({ case StudentHeaderType.TITLE: return ; case StudentHeaderType.BACK_WITH_TITLE: - return ; + return ( + + ); case StudentHeaderType.BACK_WITH_PROFILE: return ; default: diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/LecrureRecordSection/LecrureRecordSection.module.scss b/frontend/app/student/lecture-detail/[lectureId]/_components/LecrureRecordSection/LecrureRecordSection.module.scss new file mode 100644 index 00000000..58e7affc --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/LecrureRecordSection/LecrureRecordSection.module.scss @@ -0,0 +1,36 @@ +.recordSection { + height: 100%; +} + +.card { + background: white; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 16px; +} + +.title { + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; + color: #333; +} + +.audioItem { + display: flex; + flex-direction: column; + gap: 12px; +} + +.audioName { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.audioPlayer { + width: 100%; + height: 40px; +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/LecrureRecordSection/LecrureRecordSection.tsx b/frontend/app/student/lecture-detail/[lectureId]/_components/LecrureRecordSection/LecrureRecordSection.tsx new file mode 100644 index 00000000..db711ddd --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/LecrureRecordSection/LecrureRecordSection.tsx @@ -0,0 +1,116 @@ +import React, { useState, useEffect } from "react"; +import { useLectureStatusStore } from "@/store/useLectureStatusStore"; +import { FetchAudioFileResult } from "@/types/lectures/fetchAudioFileTypes"; +import { fetchAudioFile } from "@/api/lectures/fetchAudioFile"; +import { Download, Mic } from "lucide-react"; +import FileDisplay from "@/components/FileDisplay/FileDisplay"; +import IconButton from "@/components/Button/IconButton/IconButton"; +import styles from "./LecrureRecordSection.module.scss"; +import NoDataView from "@/components/NoDataView/NoDataView"; +import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner"; +import { downloadFileWithErrorHandling } from "@/utils/downloadUtils"; + +interface LecrureRecordSectionProps { + lectureId: string; +} + +export default function LecrureRecordSection({ + lectureId, +}: LecrureRecordSectionProps) { + const { lectureStatus } = useLectureStatusStore(); + const [audio, setAudio] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const getStatusText = (status: string) => { + switch (status) { + case "beforeLecture": + case "onLecture": + return "강의가 종료된 후 강의 녹음을 확인할 수 있습니다. "; + case "afterLectureBeforeQuiz": + case "quizReadyForSubmission": + case "viewMyQuizResult": + return null; + default: + return "강의가 종료된 후 강의 녹음을 확인할 수 있습니다. "; + } + }; + + const handleDownload = async () => { + if (!audio?.audioUrl) return; + + await downloadFileWithErrorHandling( + audio.audioUrl, + audio.audioName || "강의녹음본.mp3" + ); + }; + + useEffect(() => { + const fetchAudio = async () => { + try { + setLoading(true); + setError(null); + const response = await fetchAudioFile(lectureId); + + if (response.isSuccess && response.result) { + setAudio(response.result); + } else { + setAudio(null); + } + } catch (err) { + console.error("오디오 파일 조회 실패:", err); + setError("오디오 파일을 불러오는 중 오류가 발생했습니다."); + setAudio(null); + } finally { + setLoading(false); + } + }; + + fetchAudio(); + }, [lectureId]); + + if (!lectureStatus) return null; + + if (loading) return ; + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ {getStatusText(lectureStatus) !== null ? ( +
{getStatusText(lectureStatus)}
+ ) : audio ? ( +
+ + + } + onClick={handleDownload} + ariaLabel={"강의 녹음본 다운로드"} + /> + + +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/LectureInfoSection/LectureInfoSection.module.scss b/frontend/app/student/lecture-detail/[lectureId]/_components/LectureInfoSection/LectureInfoSection.module.scss new file mode 100644 index 00000000..b0005059 --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/LectureInfoSection/LectureInfoSection.module.scss @@ -0,0 +1,50 @@ +.card { + background: white; + border-radius: $radius-md; + padding: $spacing-lg; + box-shadow: 0px 0px 16px rgba($color-mutedblue, 0.3); + margin-bottom: 16px; +} + +.header { + display: flex; + align-items: center; + gap: $spacing-xs; + margin-bottom: $spacing-sm; +} + +.lectureTitle { + font-size: $font-size-lg; + margin: 0; + font-weight: $font-weight-medium; +} + +.status { + font-size: $font-size-md; + color: $color-mutedblue; + font-weight: 400; +} + +.divider { + height: 1px; + background-color: $color-neutral-7; + margin: 12px 0; +} + +.infoRow { + display: flex; + justify-content: space-between; + align-items: center; +} + +.infoItem { + display: flex; + align-items: center; + gap: 6px; + color: $color-mutedblue; + font-size: $font-size-md; +} + +.icon { + color: $color-mutedblue; +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/LectureInfoSection/LectureInfoSection.tsx b/frontend/app/student/lecture-detail/[lectureId]/_components/LectureInfoSection/LectureInfoSection.tsx new file mode 100644 index 00000000..3dc828a4 --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/LectureInfoSection/LectureInfoSection.tsx @@ -0,0 +1,123 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import styles from "./LectureInfoSection.module.scss"; +import { BookOpenText, Calendar, Clock } from "lucide-react"; +import { fetchStudentLectureDetail } from "@/api/lectures/fetchStudentLectureDetail"; +import { FetchStudentLectureDetailResult } from "@/types/lectures/fetchStudentLectureDetailTypes"; +import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner"; +import NoDataView from "@/components/NoDataView/NoDataView"; +import { useLectureStatusStore } from "@/store/useLectureStatusStore"; + +interface LectureInfoSectionProps { + lectureId: string; +} + +export default function LectureInfoSection({ + lectureId, +}: LectureInfoSectionProps) { + const { setLectureStatus, setLectureDate } = useLectureStatusStore(); + const [lectureData, setLectureData] = + useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadLectureData = async () => { + try { + setLoading(true); + const response = await fetchStudentLectureDetail(lectureId); + if (response.isSuccess && response.result) { + setLectureData(response.result); + setLectureStatus(response.result.status); + setLectureDate(response.result.lectureDate); + } else { + setError("강의 정보를 불러올 수 없습니다."); + } + } catch { + setError("강의 정보를 불러오는 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + loadLectureData(); + }, [lectureId, setLectureStatus, setLectureDate]); + const getStatusText = (status: string) => { + switch (status) { + case "beforeLecture": + return "강의 전"; + case "onLecture": + return "강의 중"; + case "afterLectureBeforeQuiz": + case "quizReadyForSubmission": + case "viewMyQuizResult": + return "강의 종료"; + default: + return "강의 중"; + } + }; + + const formatDate = (dateString: string, weekDay: string) => { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}.${month}.${day} (${weekDay})`; + }; + + const formatTime = (startTime: string, endTime: string) => { + const formatTimeString = (time: string) => { + const [hours, minutes] = time.split(":"); + const hour = parseInt(hours); + const ampm = hour >= 12 ? "PM" : "AM"; + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + return `${displayHour}:${minutes} ${ampm}`; + }; + + return `${formatTimeString(startTime)} - ${formatTimeString(endTime)}`; + }; + + if (loading) { + return ; + } + + if (error || !lectureData) { + return ( + + ); + } + + return ( +
+
+

+ {String(lectureData.session).padStart(2, "0")}.{" "} + {lectureData.lectureName} +

+ + {getStatusText(lectureData.status)} + +
+ +
+ +
+
+ + + {formatDate(lectureData.lectureDate, lectureData.weekDay)} + +
+ +
+ + {formatTime(lectureData.startTime, lectureData.endTime)} +
+
+
+ ); +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/LectureNoteListSection/LectureNoteListSection.module.scss b/frontend/app/student/lecture-detail/[lectureId]/_components/LectureNoteListSection/LectureNoteListSection.module.scss new file mode 100644 index 00000000..9ba2fdb3 --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/LectureNoteListSection/LectureNoteListSection.module.scss @@ -0,0 +1,23 @@ +.materialList { + display: flex; + flex-direction: column; + gap: $spacing-sm; + height: 100%; +} + +.materialItem { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: $spacing-xs; + border-bottom: 1px solid $color-neutral-7; + + &:last-child { + border-bottom: none; + } +} + +.size { + color: $color-neutral-6; + font-size: $font-size-sm; +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/LectureNoteListSection/LectureNoteListSection.tsx b/frontend/app/student/lecture-detail/[lectureId]/_components/LectureNoteListSection/LectureNoteListSection.tsx new file mode 100644 index 00000000..ffb37a36 --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/LectureNoteListSection/LectureNoteListSection.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { fetchLectureNoteByLectureId } from "@/api/lectures/fetchLectureNoteByLectureId"; +import FileDisplay from "@/components/FileDisplay/FileDisplay"; +import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner"; +import { FetchLectureNoteByLectureIdResult } from "@/types/lectures/fetchLectureNoteByLectureIdTypes"; +import React, { useCallback, useEffect, useState } from "react"; +import styles from "./LectureNoteListSection.module.scss"; +import NoDataView from "@/components/NoDataView/NoDataView"; +import { Download, FileText } from "lucide-react"; +import IconButton from "@/components/Button/IconButton/IconButton"; +import { downloadFileWithErrorHandling } from "@/utils/downloadUtils"; + +interface LectureNoteListSectionProps { + lectureId: string; +} + +export default function LectureNoteListSection({ + lectureId, +}: LectureNoteListSectionProps) { + const [lectureNotes, setLectureNotes] = useState< + FetchLectureNoteByLectureIdResult[] + >([]); + const [loading, setLoading] = useState(true); + + const fetchLectureNotes = useCallback(async () => { + try { + setLoading(true); + const response = await fetchLectureNoteByLectureId(lectureId); + + if (response.isSuccess && response.result) { + setLectureNotes(response.result); + } else { + console.error("강의자료 조회 실패:", response.message); + setLectureNotes([]); + } + } catch (error) { + console.error("강의자료 조회 중 오류 발생:", error); + setLectureNotes([]); + } finally { + setLoading(false); + } + }, [lectureId]); + + const handleDownload = async (note: FetchLectureNoteByLectureIdResult) => { + await downloadFileWithErrorHandling( + note.lectureNoteUrl, + note.lectureNoteName || "강의자료" + ); + }; + + useEffect(() => { + fetchLectureNotes(); + }, [lectureId, fetchLectureNotes]); + + if (loading) return ; + + return ( +
+ {lectureNotes.length === 0 ? ( + + ) : ( + lectureNotes.map((note) => ( +
+
+ +
+ } + onClick={() => handleDownload(note)} + ariaLabel={"강의자료 다운로드"} + /> +
+ )) + )} +
+ ); +} 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 new file mode 100644 index 00000000..f70ac5ca --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.module.scss @@ -0,0 +1,66 @@ +.questionListSection { + height: 100%; +} + +.questionListContainer { + display: flex; + flex-direction: column; + gap: $spacing-sm; + justify-content: space-between; + height: 100%; +} + +.questionList { + height: 100%; + overflow-y: scroll; + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.questionItem { + display: flex; + flex-direction: column; + gap: $spacing-xs; + width: fit-content; + padding: $spacing-sm; + border: 1px solid $color-neutral-7; + border-radius: $radius-md; + box-shadow: $shadow-sm; + + &:last-child { + border-bottom: none; + margin-bottom: $spacing-sm; + } +} + +.bottomSpacer { + height: 1px; +} + +.timestamp { + font-size: $font-size-sm; + color: $color-neutral-5; +} + +.message { + font-size: $font-size-md; + line-height: 1.4; + word-break: break-word; + 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; + align-items: center; +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx new file mode 100644 index 00000000..193d1329 --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx @@ -0,0 +1,132 @@ +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 { messages, connected, sendMessage } = useLectureChat(lectureId); + const [questionInput, setQuestionInput] = useState(""); + const [loading, setLoading] = useState(true); + const [previousMessages, setPreviousMessages] = useState([]); + const listEndRef = useRef(null); + + // 질문 전송 함수 + const sendQuestion = () => { + if (!questionInput.trim() || !connected) return; + + sendMessage(questionInput.trim()); + setQuestionInput(""); // 입력창 초기화 + }; + + useEffect(() => { + 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); + } + }; + loadPreviousMessages(); + return () => { + isMounted = false; + }; + }, [lectureId]); + + const combinedMessages = useMemo(() => { + // 과거 메시지 이후에 실시간 메시지 순서로 노출 + return [...previousMessages, ...messages]; + }, [previousMessages, messages]); + + // 새로운 메시지가 추가될 때 항상 맨 아래로 스크롤 + useEffect(() => { + listEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [combinedMessages]); + + // 시간 포맷팅 함수 + const formatTime = (timestamp: string) => { + try { + const date = new Date(timestamp); + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + return `${hours}:${minutes}`; + } catch { + return "00:00"; + } + }; + + if (loading) return ; + + return ( +
+ {lectureStatus === "onLecture" ? ( +
+
    + {combinedMessages.map((message, index) => ( +
  • +
    +
    {message.content}
    +
    +
    + {formatTime(message.timestamp)} +
    + {message.role === "TEACHER" && ( +
    + * 강사가 보낸 메시지입니다. +
    + )} +
  • + ))} +
  • +
+
+ setQuestionInput(e.target.value)} + placeholder="질문을 입력해주세요." + /> + } + onClick={sendQuestion} + ariaLabel={"전송"} + disabled={!connected} + /> +
+
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/QuizSection/QuizSection.module.scss b/frontend/app/student/lecture-detail/[lectureId]/_components/QuizSection/QuizSection.module.scss new file mode 100644 index 00000000..0e51539c --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/QuizSection/QuizSection.module.scss @@ -0,0 +1,19 @@ +.quizSection { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.quizContainer { + display: flex; + flex-direction: column; + gap: $spacing-md; + height: 100%; + overflow-y: auto; +} + +.buttonContainer { + margin-top: $spacing-md; + flex: 1; +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/_components/QuizSection/QuizSection.tsx b/frontend/app/student/lecture-detail/[lectureId]/_components/QuizSection/QuizSection.tsx new file mode 100644 index 00000000..bc51172a --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/QuizSection/QuizSection.tsx @@ -0,0 +1,315 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import dayjs from "dayjs"; +import { CheckCircle, Clock } from "lucide-react"; + +// API +import { fetchQuizList } from "@/api/quizzes/fetchQuizList"; +import { submitQuiz } from "@/api/quizzes/submitQuiz"; +import { getMyQuizResult } from "@/api/quizzes/getMyQuizResult"; + +// Types +import { fetchQuizListResult } from "@/types/quizzes/fetchQuizListTypes"; +import { SubmitQuizRequest } from "@/types/quizzes/submitQuizTypes"; +import { getMyQuizResultResult } from "@/types/quizzes/getMyQuizResultTypes"; + +// Components +import QuizToggleCard from "@/components/QuizToggleCard/QuizToggleCard"; +import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner"; +import NoDataView from "@/components/NoDataView/NoDataView"; +import AlertModal from "@/components/Modal/AlertModal/AlertModal"; +import FullWidthButton from "@/components/Button/FullWidthButton/FullWidthButton"; + +// Store +import { useLectureStatusStore } from "@/store/useLectureStatusStore"; + +// Styles +import styles from "./QuizSection.module.scss"; + +interface QuizSectionProps { + lectureId: string; + onRefresh?: () => void; +} + +export type QuizStatus = + | "before" + | "notYet" + | "solve" + | "waitingResult" + | "viewResult"; + +export default function QuizSection({ + lectureId, + onRefresh, +}: QuizSectionProps) { + const { lectureStatus, lectureDate } = useLectureStatusStore(); + const [quizStatus, setQuizStatus] = useState("notYet"); + const [quizData, setQuizData] = useState(null); + const [quizResultData, setQuizResultData] = + useState(null); + const [userAnswers, setUserAnswers] = useState<{ [quizId: string]: string }>( + {} + ); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [showResultModal, setShowResultModal] = useState(false); + const [resultMessage, setResultMessage] = useState(""); + const [refreshKey, setRefreshKey] = useState(0); + + // 강의 상태에 따른 퀴즈 상태 설정 + useEffect(() => { + const canViewResult = () => { + if (!lectureDate) return false; + const midnight = dayjs(lectureDate + " 00:00").add(1, "day"); + const now = dayjs( + new Date(new Date().toLocaleString("en-US", { timeZone: "Asia/Seoul" })) + ); + return now.isAfter(midnight); + }; + + switch (lectureStatus) { + case "beforeLecture": + case "onLecture": + setQuizStatus("before"); + break; + case "afterLectureBeforeQuiz": + setQuizStatus("notYet"); + break; + case "quizReadyForSubmission": + setQuizStatus("solve"); + break; + case "viewMyQuizResult": + setQuizStatus(canViewResult() ? "viewResult" : "waitingResult"); + break; + } + }, [lectureStatus, lectureDate]); + + // 퀴즈 데이터 로드 + useEffect(() => { + const loadQuizData = async () => { + if (quizStatus !== "solve") return; + + setLoading(true); + try { + const response = await fetchQuizList(lectureId); + if (response.isSuccess && response.result) { + setQuizData(response.result); + } + } catch (error) { + console.error("퀴즈 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + loadQuizData(); + }, [quizStatus, lectureId]); + + // 퀴즈 결과 데이터 로드 + useEffect(() => { + const loadQuizResultData = async () => { + if (quizStatus !== "viewResult") return; + + setLoading(true); + try { + const response = await getMyQuizResult(lectureId); + if (response.isSuccess && response.result) { + setQuizResultData(response.result); + } + } catch (error) { + console.error("퀴즈 결과 데이터 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + loadQuizResultData(); + }, [quizStatus, lectureId]); + + // 퀴즈 답변 핸들러 + const handleQuizSelect = (quizId: string, answer: string) => { + setUserAnswers((prev) => ({ + ...prev, + [quizId]: answer, + })); + }; + + const handleQuizInputChange = (quizId: string, inputAnswer: string) => { + setUserAnswers((prev) => ({ + ...prev, + [quizId]: inputAnswer, + })); + }; + + // 퀴즈 제출 + const handleSubmitQuiz = async () => { + if (!quizData) return; + + setSubmitting(true); + try { + const submitData: SubmitQuizRequest = { + answers: Object.entries(userAnswers).map(([quizId, answer]) => ({ + quizId, + answer, + })), + }; + + const response = await submitQuiz(submitData); + + if (response.isSuccess && response.result) { + setResultMessage( + "성공적으로 제출되었습니다. 12시 이후 퀴즈 결과를 확인할 수 있습니다." + ); + } else { + setResultMessage(response.message || "퀴즈 제출에 실패했습니다."); + } + } catch (error) { + console.error("퀴즈 제출 오류:", error); + setResultMessage("퀴즈 제출 중 오류가 발생했습니다."); + } finally { + setSubmitting(false); + setShowResultModal(true); + } + }; + + // 로딩 상태 + if (loading) { + return ; + } + + // 퀴즈 시작 전 + if (quizStatus === "before") { + return ( +
+ +
+ ); + } + + if (quizStatus === "notYet") { + return ( +
+ +
+ ); + } + + // 결과 대기 중 + if (quizStatus === "waitingResult") { + return ( +
+ +
+ ); + } + + // 결과 확인 가능 + if (quizStatus === "viewResult") { + if (loading) { + return ; + } + + if (!quizResultData) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {quizResultData.quizzes.map((quiz) => ( + option.text) + : undefined + } + userAnswer={quiz.studentAnswer} + correctAnswer={quiz.solution} + /> + ))} +
+
+ ); + } + + // 퀴즈 풀이 화면 + return ( +
+ {quizStatus === "solve" && quizData && ( +
+ {quizData.quizzes.map((quiz) => ( + option.text) + } + onSelect={(label) => handleQuizSelect(quiz.quizId, label)} + onInputChange={(inputAnswer) => + handleQuizInputChange(quiz.quizId, inputAnswer) + } + /> + ))} +
+ )} + +
+ + {submitting ? "제출 중..." : "퀴즈 제출"} + +
+ + {showResultModal && ( + { + setShowResultModal(false); + setRefreshKey((prev) => prev + 1); + onRefresh?.(); + }} + > + {resultMessage} + + )} +
+ ); +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/page.module.scss b/frontend/app/student/lecture-detail/[lectureId]/page.module.scss index e69de29b..866c5d6e 100644 --- a/frontend/app/student/lecture-detail/[lectureId]/page.module.scss +++ b/frontend/app/student/lecture-detail/[lectureId]/page.module.scss @@ -0,0 +1,12 @@ +.lectureDetailPage { + height: 94vh; + display: flex; + flex-direction: column; + padding: $spacing-lg; +} + +.content { + height: 100%; + margin-top: $spacing-lg; + overflow-y: auto; +} diff --git a/frontend/app/student/lecture-detail/[lectureId]/page.tsx b/frontend/app/student/lecture-detail/[lectureId]/page.tsx index 1e417598..6269dc59 100644 --- a/frontend/app/student/lecture-detail/[lectureId]/page.tsx +++ b/frontend/app/student/lecture-detail/[lectureId]/page.tsx @@ -1,3 +1,69 @@ +"use client"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import LectureInfoSection from "./_components/LectureInfoSection/LectureInfoSection"; +import LectureNoteListSection from "./_components/LectureNoteListSection/LectureNoteListSection"; +import QuestionListSection from "./_components/QuestionListSection/QuestionListSection"; +import LecrureRecordSection from "./_components/LecrureRecordSection/LecrureRecordSection"; +import QuizSection from "./_components/QuizSection/QuizSection"; +import style from "./page.module.scss"; +import Tab from "@/components/Tab/Tab"; +import { useClassTitleStore } from "@/store/useClassTitleStore"; +import { fetchClassNameByLectureId } from "@/api/classes/fetchClassNameByLectureId"; + export default function StudentLectureDetailPage() { - return
강의 상세
; + const params = useParams(); + const { setClassTitle } = useClassTitleStore(); + const lectureId = params.lectureId as string; + const [selectedTab, setSelectedTab] = useState(0); + const [quizRefreshKey, setQuizRefreshKey] = useState(0); + + useEffect(() => { + (async () => { + try { + const res = await fetchClassNameByLectureId(lectureId); + if (res.isSuccess) { + setClassTitle(res.result?.className || ""); + } + } catch (error) { + console.error("클래스 이름을 불러오는 중 오류 발생:", error); + } + })(); + }, [lectureId, setClassTitle]); + + const tabs = ["수업 자료", "질문하기", "강의 녹음", "복습 퀴즈"]; + + const handleTabSelect = (tabName: string) => { + const tabIndex = tabs.indexOf(tabName); + setSelectedTab(tabIndex); + }; + + const renderSection = () => { + switch (selectedTab) { + case 0: + return ; + case 1: + return ; + case 2: + return ; + case 3: + return ( + setQuizRefreshKey((prev) => prev + 1)} + /> + ); + default: + return ; + } + }; + + return ( +
+ + +
{renderSection()}
+
+ ); } diff --git a/frontend/app/teacher/lecture-detail/[lectureId]/_components/LectureRecording/LectureRecording.tsx b/frontend/app/teacher/lecture-detail/[lectureId]/_components/LectureRecording/LectureRecording.tsx index 3ba4a462..9b1243da 100644 --- a/frontend/app/teacher/lecture-detail/[lectureId]/_components/LectureRecording/LectureRecording.tsx +++ b/frontend/app/teacher/lecture-detail/[lectureId]/_components/LectureRecording/LectureRecording.tsx @@ -8,6 +8,7 @@ import { fetchAudioFile } from "@/api/lectures/fetchAudioFile"; import { useLectureDetail } from "../LectureDetailContext"; import { FetchAudioFileResult } from "@/types/lectures/fetchAudioFileTypes"; import IconButton from "@/components/Button/IconButton/IconButton"; +import { downloadFileWithErrorHandling } from "@/utils/downloadUtils"; export default function LectureRecording() { const { lectureId } = useLectureDetail(); @@ -18,25 +19,10 @@ export default function LectureRecording() { const handleDownload = async () => { if (!audio?.audioUrl) return; - try { - const response = await fetch(audio.audioUrl); - if (!response.ok) { - throw new Error("다운로드에 실패했습니다."); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = audio.audioName || "강의녹음본.mp3"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); - } catch (err) { - console.error("다운로드 실패:", err); - alert("다운로드 중 오류가 발생했습니다."); - } + await downloadFileWithErrorHandling( + audio.audioUrl, + audio.audioName || "강의녹음본.mp3" + ); }; useEffect(() => { diff --git a/frontend/app/teacher/lecture-detail/[lectureId]/_components/QuestionList/QuestionList.tsx b/frontend/app/teacher/lecture-detail/[lectureId]/_components/QuestionList/QuestionList.tsx index e3f2fa55..8dc06ff5 100644 --- a/frontend/app/teacher/lecture-detail/[lectureId]/_components/QuestionList/QuestionList.tsx +++ b/frontend/app/teacher/lecture-detail/[lectureId]/_components/QuestionList/QuestionList.tsx @@ -6,7 +6,7 @@ import Image from "next/image"; import { IMAGES } from "@/constants/images"; import { useLectureDetail } from "../LectureDetailContext"; -interface Question { +export interface Question { sender: string; message: string; timestamp: string; diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.module.scss b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingButton/ChattingButton.module.scss similarity index 100% rename from frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.module.scss rename to frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingButton/ChattingButton.module.scss diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingButton/ChattingButton.tsx similarity index 92% rename from frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.tsx rename to frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingButton/ChattingButton.tsx index 2bda7008..c43b7d2c 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingButton/ChatingButton.tsx +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingButton/ChattingButton.tsx @@ -4,9 +4,9 @@ import { useEffect, useState } from "react"; import IconButton from "@/components/Button/IconButton/IconButton"; import { MessageCircleMore } from "lucide-react"; import { useLive } from "../../LectureLiveProvider"; -import styles from "./ChatingButton.module.scss"; +import styles from "./ChattingButton.module.scss"; -export default function ChatingButton({ +export default function ChattingButton({ className, onPress, }: { diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.module.scss b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingPanel/ChattingPanel.module.scss similarity index 100% rename from frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.module.scss rename to frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingPanel/ChattingPanel.module.scss diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingPanel/ChattingPanel.tsx similarity index 63% rename from frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.tsx rename to frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingPanel/ChattingPanel.tsx index 9beefc2e..e2f8510a 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/Chating/ChatingPanel/ChatingPanel.tsx +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/Chatting/ChattingPanel/ChattingPanel.tsx @@ -1,16 +1,17 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import styles from "./ChatingPanel.module.scss"; +import { useEffect, useRef, useState, useMemo } from "react"; +import styles from "./ChattingPanel.module.scss"; import IconButton from "@/components/Button/IconButton/IconButton"; import { X, SendHorizontal } from "lucide-react"; import { useLive } from "../../LectureLiveProvider"; import ChatBox from "@/components/ChatBox/ChatBox"; import BasicInput from "@/components/Input/BasicInput/BasicInput"; import { useParams } from "next/navigation"; -import { useLectureChat } from "@/hooks/useLectureChat"; +import { useLectureChat, type ChatMessage } from "@/hooks/useLectureChat"; +import { fetchChattingList } from "@/api/lectures/fetchChattingList"; -export default function ChatPanel() { +export default function ChattingPanel() { const { togglePanel } = useLive(); const { lectureId } = useParams<{ lectureId: string }>(); @@ -20,6 +21,9 @@ export default function ChatPanel() { const [text, setText] = useState(""); const bodyRef = useRef(null); + const [previousMessages, setPreviousMessages] = useState([]); + const fetchedOnceRef = useRef(false); + const closeChat = () => togglePanel("chat"); // 메시지 전송 @@ -36,13 +40,53 @@ export default function ChatPanel() { send(); }; + // 연결 직후 과거 대화 불러오기 + useEffect(() => { + if (!lectureId || !connected || fetchedOnceRef.current) return; + + let isMounted = true; + (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 (e) { + setPreviousMessages([]); + console.error("과거 채팅 불러오기 실패:", e); + } finally { + fetchedOnceRef.current = true; + } + })(); + + return () => { + isMounted = false; + }; + }, [lectureId, connected]); + + // 과거 + 실시간 합친 배열 + const combinedMessages = useMemo( + () => [...previousMessages, ...messages], + [previousMessages, messages] + ); + // 새로운 메시지 오면 스크롤 맨 아래로 이동 useEffect(() => { const el = bodyRef.current; if (!el) return; el.scrollTop = el.scrollHeight; - }, [messages]); - + }, [combinedMessages]); const fmt = (ts: string) => { const d = new Date(ts); @@ -66,7 +110,7 @@ export default function ChatPanel() {
- {messages.map((m, i) => ( + {combinedMessages.map((m, i) => (
(); const handleConfirmEnd = async () => { - if (isRecording) { - try { - setSaving(true); + if (!lectureId) { + console.error("lectureId가 없습니다."); + return; + } + + setSaving(true); + + try { + // 녹음 중일 경우 녹음 저장 + if (isRecording) { + + await new Promise((resolve, reject) => { const off = engine.subscribe("done", async (blob) => { try { - if (lectureId) { - await saveAudioFile(lectureId, blob); - console.log("🎤 녹음 파일 저장 완료"); - } + await saveAudioFile(lectureId, blob); + console.log("🎤 녹음 파일 저장 완료"); off(); resolve(); } catch (e) { console.error("❌ 녹음 파일 저장 실패:", e); + off(); reject(e); } }); - engine.stop().catch(reject); }); - } catch (e) { - console.error("녹음 종료 중 오류:", e); - } finally { - setSaving(false); } - } + + // 채팅 저장 + const chatRes = await saveChatting(lectureId); - setEndOpen(false); - onEndLecture?.(); - router.push(ROUTES.teacherLectureDetail(lectureId)); + if (!chatRes?.isSuccess) { + console.warn("채팅 저장 실패:", chatRes?.code, chatRes?.message); + } + + setEndOpen(false); + onEndLecture?.(); + router.push(ROUTES.teacherLectureDetail(lectureId)); + } catch (e) { + console.error("강의 종료 처리 중 오류:", e); + } finally { + setSaving(false); + } }; const handleCancelEnd = () => setEndOpen(false); @@ -100,7 +115,7 @@ export default function LectureLiveHeader({ - { closePen(); onToggleChat?.(); @@ -120,7 +135,7 @@ export default function LectureLiveHeader({ disableActions={saving} > {saving ? ( - <>녹음 파일 저장 중입니다... ⏳ + <>저장 중입니다... ⏳ ) : isRecording ? ( <> 지금 녹음이 진행 중입니다. diff --git a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx index 9f552e11..75b11d37 100644 --- a/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx +++ b/frontend/app/teacher/lecture-live/[lectureId]/_components/LectureMainGrid/LectureMainGrid.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import styles from "./LectureMainGrid.module.scss"; import { useLive } from "../LectureLiveProvider"; -import ChatPanel from "../Chating/ChatingPanel/ChatingPanel"; +import ChattingPanel from "../Chatting/ChattingPanel/ChattingPanel"; import dynamic from "next/dynamic"; import { FileText } from "lucide-react"; @@ -119,7 +119,7 @@ export default function LectureMainGrid() { ); 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/components/Button/FullWidthButton/FullWidthButton.module.scss b/frontend/components/Button/FullWidthButton/FullWidthButton.module.scss index 3382c02b..4d123882 100644 --- a/frontend/components/Button/FullWidthButton/FullWidthButton.module.scss +++ b/frontend/components/Button/FullWidthButton/FullWidthButton.module.scss @@ -13,7 +13,6 @@ &:hover { background-color: $color-lightblue; - transform: scale(1.02); cursor: pointer; } diff --git a/frontend/components/Button/QuizChoiceButton/QuizChoiceButton.tsx b/frontend/components/Button/QuizChoiceButton/QuizChoiceButton.tsx index f8f011d1..cf5ea69c 100644 --- a/frontend/components/Button/QuizChoiceButton/QuizChoiceButton.tsx +++ b/frontend/components/Button/QuizChoiceButton/QuizChoiceButton.tsx @@ -15,7 +15,6 @@ type ResultModeProps = { label: string; userAnswer: string; // 사용자 답변 correctAnswer: string; // 정답 - count: number; // 선택한 사람 수 }; type TeacherModeProps = { @@ -99,10 +98,9 @@ const QuizChoiceButton: React.FC = (props) => { disabled={isDisabled} > {props.label} - {(isResultMode(props) || isTeacherMode(props)) && - props.count !== undefined && ( - ({props.count}명) - )} + {isTeacherMode(props) && props.count !== undefined && ( + ({props.count}명) + )} ); }; diff --git a/frontend/components/FileDisplay/FileDisplay.module.scss b/frontend/components/FileDisplay/FileDisplay.module.scss index 5809b686..3e16b45e 100644 --- a/frontend/components/FileDisplay/FileDisplay.module.scss +++ b/frontend/components/FileDisplay/FileDisplay.module.scss @@ -27,3 +27,14 @@ text-overflow: ellipsis; } } + +.fileInfoContainer { + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.size { + font-size: $font-size-sm; + color: $color-neutral-6; +} diff --git a/frontend/components/FileDisplay/FileDisplay.tsx b/frontend/components/FileDisplay/FileDisplay.tsx index f047fd2e..13dab913 100644 --- a/frontend/components/FileDisplay/FileDisplay.tsx +++ b/frontend/components/FileDisplay/FileDisplay.tsx @@ -23,9 +23,10 @@ const fileIcons: { [key: string]: StaticImageData } = { type FileDisplayProps = { fileName: string; + size?: string; }; -const FileDisplay: React.FC = ({ fileName }) => { +const FileDisplay: React.FC = ({ fileName, size }) => { // 파일 확장자 추출 const fileExtension = fileName.split(".").pop()?.toLowerCase(); @@ -81,8 +82,10 @@ const FileDisplay: React.FC = ({ fileName }) => { height={24} />
- - {fileName} +
+ {fileName} + {size} +
); }; diff --git a/frontend/components/Input/QuizInput/QuizInput.tsx b/frontend/components/Input/QuizInput/QuizInput.tsx index 6f4be664..ba78fdd8 100644 --- a/frontend/components/Input/QuizInput/QuizInput.tsx +++ b/frontend/components/Input/QuizInput/QuizInput.tsx @@ -83,7 +83,6 @@ const QuizInput: React.FC = (props) => { {isResultMode(props) && props.userAnswer !== props.correctAnswer && (

정답: {props.correctAnswer}

-

({props.count}명)

)} diff --git a/frontend/components/QuizToggleCard/QuizToggleCard.tsx b/frontend/components/QuizToggleCard/QuizToggleCard.tsx index afb5625d..b95ada6f 100644 --- a/frontend/components/QuizToggleCard/QuizToggleCard.tsx +++ b/frontend/components/QuizToggleCard/QuizToggleCard.tsx @@ -25,8 +25,6 @@ type ResultModeProps = { labels?: string[]; // multipleChoice 타입일때만 필요 userAnswer: string; // 사용자 답변 correctAnswer: string; // 정답 - counts: { [key: string]: number }; // 각 라벨에 대한 선택자 수 - correctRate: number; // 정답률 }; type TeacherModeProps = { @@ -89,7 +87,7 @@ const QuizToggleCard: React.FC = (props) => { return (
{props.type === "multipleChoice" || props.type === "trueFalse" ? ( - getLabels()?.map((label) => ( + props.labels?.map((label) => ( = (props) => { label={label} userAnswer={props.userAnswer} correctAnswer={props.correctAnswer} - count={props.counts[label]} // 해당 label에 대한 선택자 수 mode="result" /> )) @@ -177,7 +174,7 @@ const QuizToggleCard: React.FC = (props) => { /> )}

퀴즈 {props.quizIndex}

- {(props.mode === "result" || props.mode === "teacher") && ( + {props.mode === "teacher" && (

정답률: {props.correctRate}%

)}
diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index bebd6480..2e4f0f91 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -68,6 +68,12 @@ export const ENDPOINTS = { `${BASE_API}/lectures/teacher/today?date=${date}`, GET_STUDENT_LECTURES_BY_DATE: (date: string) => `${BASE_API}/lectures/student/today?date=${date}`, + GET_STUDENT_LECTURE_DETAIL: (lectureId: string) => + `${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) => @@ -88,12 +94,12 @@ export const ENDPOINTS = { `${BASE_API}/lectures/${lectureId}/recordings`, GET_RECORDING: (lectureId: string) => `${BASE_API}/lectures/${lectureId}/recordings`, - + // 채팅 관련 SAVE_CHAT: (lectureId: string) => - `${BASE_API}/lectures/${lectureId}/chating`, + `${BASE_API}/lectures/chatting/after/${lectureId}`, GET_CHAT: (lectureId: string) => - `${BASE_API}/lectures/${lectureId}/chating`, + `${BASE_API}/lectures/${lectureId}/chatting`, }, // 퀴즈 관련 @@ -104,8 +110,8 @@ export const ENDPOINTS = { SAVE: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/save`, UPDATE: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}`, GET: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}`, - SUBMIT: (lectureId: string) => `${BASE_API}/quizzes/${lectureId}/submit`, + SUBMIT: `${BASE_API}/quizzes/submit`, GET_RESULT: (lectureId: string) => - `${BASE_API}/quizzes/${lectureId}/result`, + `${BASE_API}/quizzes/${lectureId}/result/student`, }, }; diff --git a/frontend/hooks/useLectureChat.ts b/frontend/hooks/useLectureChat.ts index a991ece1..b59c61f2 100644 --- a/frontend/hooks/useLectureChat.ts +++ b/frontend/hooks/useLectureChat.ts @@ -48,6 +48,12 @@ export function useLectureChat(lectureId: string | undefined) { try { const parsed: ChatMessage = JSON.parse(message.body); setMessages((prev) => [...prev, parsed]); + + if (typeof window !== "undefined") { + if (parsed.role !== "TEACHER") { + window.dispatchEvent(new CustomEvent("live:chat:new", { detail: parsed })); + } + } } catch (e) { console.error("❌ 메시지 파싱 실패:", e); } diff --git a/frontend/hooks/useLectureStatusAction.ts b/frontend/hooks/useLectureStatusAction.ts index 93822813..0151ed6d 100644 --- a/frontend/hooks/useLectureStatusAction.ts +++ b/frontend/hooks/useLectureStatusAction.ts @@ -2,12 +2,7 @@ import { useRouter } from "next/navigation"; import { ROUTES } from "@/constants/routes"; import dayjs from "dayjs"; import { ChevronRight } from "lucide-react"; - -export type LectureStatus = - | "beforeLecture" - | "onLecture" - | "makeQuiz" - | "checkDashboard"; +import { LectureStatus } from "@/types/lectures/fetchLectureDetailTypes"; interface UseLectureStatusActionProps { status: LectureStatus; diff --git a/frontend/store/resetAllStores.ts b/frontend/store/resetAllStores.ts deleted file mode 100644 index 7c5fef37..00000000 --- a/frontend/store/resetAllStores.ts +++ /dev/null @@ -1,31 +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"; - -/** - * 모든 스토어를 초기 상태로 리셋하는 함수 - * 로그아웃이나 토큰 만료 시 사용 - */ -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(); - - 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/store/useClassTitleStore.ts b/frontend/store/useClassTitleStore.ts new file mode 100644 index 00000000..57fbc801 --- /dev/null +++ b/frontend/store/useClassTitleStore.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +interface ClassTitleStore { + classTitle: string; + setClassTitle: (title: string) => void; + clearClassTitle: () => void; +} + +export const useClassTitleStore = create((set) => ({ + classTitle: "", + setClassTitle: (title: string) => set({ classTitle: title }), + clearClassTitle: () => set({ classTitle: "" }), +})); diff --git a/frontend/store/useLectureStatusStore.ts b/frontend/store/useLectureStatusStore.ts new file mode 100644 index 00000000..9e404af8 --- /dev/null +++ b/frontend/store/useLectureStatusStore.ts @@ -0,0 +1,19 @@ +import { StudentLectureStatus } from "@/types/lectures/fetchStudentLectureDetailTypes"; +import { create } from "zustand"; + +interface LectureStatusStore { + lectureStatus: StudentLectureStatus | null; + lectureDate: string | null; + setLectureDate: (date: string) => void; + setLectureStatus: (status: StudentLectureStatus) => void; + clearLectureStatus: () => void; +} + +export const useLectureStatusStore = create((set) => ({ + lectureStatus: null, + lectureDate: null, + setLectureDate: (date: string) => set({ lectureDate: date }), + setLectureStatus: (status: StudentLectureStatus) => + set({ lectureStatus: status }), + clearLectureStatus: () => set({ lectureStatus: null }), +})); diff --git a/frontend/types/classes/fetchClassNameByLectureIdTypes.ts b/frontend/types/classes/fetchClassNameByLectureIdTypes.ts new file mode 100644 index 00000000..f268863e --- /dev/null +++ b/frontend/types/classes/fetchClassNameByLectureIdTypes.ts @@ -0,0 +1,3 @@ +export interface FetchClassNameByLectureIdResult { + className: string; +} diff --git a/frontend/types/classes/fetchMyClassListTypes.ts b/frontend/types/classes/fetchMyClassListTypes.ts index 13b91b95..65ad7922 100644 --- a/frontend/types/classes/fetchMyClassListTypes.ts +++ b/frontend/types/classes/fetchMyClassListTypes.ts @@ -4,4 +4,5 @@ export interface FetchMyClassListResult { startDate: string; endDate: string; classDate: string; + professorName: string; } 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; +}[]; diff --git a/frontend/types/lectures/fetchLectureDetailTypes.ts b/frontend/types/lectures/fetchLectureDetailTypes.ts index 04d29aa3..9c78884f 100644 --- a/frontend/types/lectures/fetchLectureDetailTypes.ts +++ b/frontend/types/lectures/fetchLectureDetailTypes.ts @@ -7,5 +7,11 @@ export interface FetchLectureDetailResult { session: number; startTime: string; endTime: string; - status: "beforeLecture" | "onLecture" | "makeQuiz" | "checkDashboard"; + status: LectureStatus; } + +export type LectureStatus = + | "beforeLecture" + | "onLecture" + | "makeQuiz" + | "checkDashboard"; diff --git a/frontend/types/lectures/fetchStudentLectureDetailTypes.ts b/frontend/types/lectures/fetchStudentLectureDetailTypes.ts new file mode 100644 index 00000000..20d1eda2 --- /dev/null +++ b/frontend/types/lectures/fetchStudentLectureDetailTypes.ts @@ -0,0 +1,18 @@ +export interface FetchStudentLectureDetailResult { + lectureId: string; + classId: string; + lectureName: string; + lectureDate: string; + weekDay: string; + session: number; + startTime: string; + endTime: string; + status: StudentLectureStatus; +} + +export type StudentLectureStatus = + | "beforeLecture" + | "onLecture" + | "afterLectureBeforeQuiz" + | "quizReadyForSubmission" + | "viewMyQuizResult"; diff --git a/frontend/types/quizzes/fetchQuizListTypes.ts b/frontend/types/quizzes/fetchQuizListTypes.ts new file mode 100644 index 00000000..cde4c1f3 --- /dev/null +++ b/frontend/types/quizzes/fetchQuizListTypes.ts @@ -0,0 +1,19 @@ +export interface fetchQuizListResult { + lectureId: string; + quizzes: Quiz[]; +} + +type Quiz = { + quizId: string; + quizOrder: number; + quizBody: string; + solution: string; + type: "multipleChoice" | "shortAnswer" | "trueFalse"; + options: QuizOption[] | []; +}; + +type QuizOption = { + id: string; + optionOrder: number; + text: string; +}; diff --git a/frontend/types/quizzes/getMyQuizResultTypes.ts b/frontend/types/quizzes/getMyQuizResultTypes.ts new file mode 100644 index 00000000..1cb395fb --- /dev/null +++ b/frontend/types/quizzes/getMyQuizResultTypes.ts @@ -0,0 +1,45 @@ +export interface getMyQuizResultResult { + lectureId: string; + quizzes: Quiz[]; +} + +type Quiz = MultipleChoiceQuiz | ShortAnswerQuiz | TrueFalseQuiz; + +type MultipleChoiceQuiz = { + quizId: string; + quizOrder: number; + quizBody: string; + solution: string; + type: "multipleChoice"; + studentAnswer: string; + options: QuizOption[]; + isCollect: boolean; +}; + +type ShortAnswerQuiz = { + quizId: string; + quizOrder: number; + quizBody: string; + solution: string; + type: "shortAnswer"; + studentAnswer: string; + options: []; + isCollect: boolean; +}; + +type TrueFalseQuiz = { + quizId: string; + quizOrder: number; + quizBody: string; + solution: string; + type: "trueFalse"; + studentAnswer: string; + options: []; + isCollect: boolean; +}; + +type QuizOption = { + id: string; + optionOrder: number; + text: string; +}; diff --git a/frontend/types/quizzes/submitQuizTypes.ts b/frontend/types/quizzes/submitQuizTypes.ts new file mode 100644 index 00000000..9fc12cb1 --- /dev/null +++ b/frontend/types/quizzes/submitQuizTypes.ts @@ -0,0 +1,13 @@ +export interface SubmitQuizRequest { + answers: Answer[]; +} + +type Answer = { + quizId: string; + answer: string; +}; + +export interface SubmitQuizResult { + userId: string; + savedCount: number; +} diff --git a/frontend/utils/downloadUtils.ts b/frontend/utils/downloadUtils.ts new file mode 100644 index 00000000..bcafa31c --- /dev/null +++ b/frontend/utils/downloadUtils.ts @@ -0,0 +1,63 @@ +/** + * 파일 다운로드 유틸리티 함수 + * @param url - 다운로드할 파일의 URL + * @param fileName - 다운로드될 파일명 (기본값: "파일") + * @returns Promise + */ +export const downloadFile = async ( + url: string, + fileName: string = "파일" +): Promise => { + if (!url) { + throw new Error("다운로드 URL이 제공되지 않았습니다."); + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`다운로드에 실패했습니다. (${response.status})`); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = downloadUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + } catch (error) { + console.error("파일 다운로드 실패:", error); + throw error; + } +}; + +/** + * 파일 다운로드 함수 (에러 처리 포함) + * @param url - 다운로드할 파일의 URL + * @param fileName - 다운로드될 파일명 (기본값: "파일") + * @param onError - 에러 발생 시 실행할 콜백 함수 (선택사항) + */ +export const downloadFileWithErrorHandling = async ( + url: string, + fileName: string = "파일", + onError?: (error: Error) => void +): Promise => { + try { + await downloadFile(url, fileName); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : "다운로드 중 오류가 발생했습니다."; + console.error("다운로드 실패:", error); + + if (onError) { + onError(error instanceof Error ? error : new Error(errorMessage)); + } else { + alert(errorMessage); + } + } +};