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/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/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/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..c16fec61 --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.module.scss @@ -0,0 +1,53 @@ +.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 { + 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; + } +} + +.timestamp { + margin-top: $spacing-xs; + 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; +} + +.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..c8c5c684 --- /dev/null +++ b/frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { useLectureStatusStore } from "@/store/useLectureStatusStore"; +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"; + +export default function QuestionListSection({ + lectureId, +}: { + lectureId: string; +}) { + const { lectureStatus } = useLectureStatusStore(); + const [questions, setQuestions] = useState([]); + 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 sendQuestion = () => { + if (!questionInput.trim() || !socketRef.current) return; + + const message = { + type: "sendQuestion", + lectureId: lectureId, + question: questionInput.trim(), + timestamp: new Date().toISOString(), + }; + + socketRef.current.send(JSON.stringify(message)); + setQuestionInput(""); // 입력창 초기화 + }; + + useEffect(() => { + // TODO: API 호출로 변경 + setQuestions([ + "dd", + "AsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfas", + "Asdfadfg", + "Asdfadfg", + ]); + setLoading(false); + + // 강의 중일 때만 소켓 연결 + if (lectureStatus === "onLecture") { + connectSocket(); + } + + // 컴포넌트 언마운트 시 소켓 연결 해제 + return () => { + if (socketRef.current) { + socketRef.current.close(); + } + }; + }, [lectureId, lectureStatus, connectSocket]); + + const now = () => { + try { + const date = new Date(); + 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" ? ( +
+
    + {questions.map((q, index) => ( +
  • +
    {q}
    +
    {now()}
    +
  • + ))} +
+
+ setQuestionInput(e.target.value)} + placeholder="질문을 입력해주세요." + /> + } + onClick={sendQuestion} + ariaLabel={"전송"} + /> +
+
+ ) : ( + + )} +
+ ); +} 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/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..18ae98e7 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -68,6 +68,10 @@ 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}`, // 노트 관련 UPLOAD_NOTE: (classId: string) => @@ -104,8 +108,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/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/next.config.ts b/frontend/next.config.ts index 85c702c7..be5f704a 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -20,9 +20,9 @@ const nextConfig: NextConfig = { images: { remotePatterns: [ { - protocol: 'https', - hostname: 'kwclasslog.s3.ap-southeast-2.amazonaws.com', - pathname: '/**', + protocol: "https", + hostname: "kwclasslog.s3.ap-southeast-2.amazonaws.com", + pathname: "/**", }, ], }, diff --git a/frontend/store/resetAllStores.ts b/frontend/store/resetAllStores.ts index 7c5fef37..8f052884 100644 --- a/frontend/store/resetAllStores.ts +++ b/frontend/store/resetAllStores.ts @@ -4,6 +4,7 @@ import useLectureListStore from "./useLectureListStore"; import useSelectedClassStore from "./useSelectedClassStore"; import useClassListStore from "./useClassListStore"; import { useSignupStore } from "./useSignupStore"; +import { useLectureStatusStore } from "./useLectureStatusStore"; /** * 모든 스토어를 초기 상태로 리셋하는 함수 @@ -26,6 +27,7 @@ export const resetAllStores = () => { useSelectedClassStore.getState().reset(); useClassListStore.getState().reset(); useSignupStore.getState().reset(); + useLectureStatusStore.getState().clearLectureStatus(); 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/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); + } + } +};