From 68fcc2fe5c420da5599602f0c7288a4bbf1f38aa Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sun, 3 Aug 2025 15:33:21 +0900 Subject: [PATCH 01/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=ED=95=99=EC=83=9D?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=AA=A9=EB=A1=9D=20=EC=84=B9=EC=85=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AddClassSection.module.scss | 27 ++++++++++ .../AddClassSection/AddClassSection.tsx | 24 +++++++++ .../EnterClassModal.module.scss | 53 +++++++++++++++++++ .../EnterClassModal/EnterClassModal.tsx | 39 ++++++++++++++ .../ClassListSection/ClassListSection.tsx | 5 ++ frontend/app/student/class/page.tsx | 11 +++- 6 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 frontend/app/student/class/_components/AddClassSection/AddClassSection.module.scss create mode 100644 frontend/app/student/class/_components/AddClassSection/AddClassSection.tsx create mode 100644 frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.module.scss create mode 100644 frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx create mode 100644 frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx diff --git a/frontend/app/student/class/_components/AddClassSection/AddClassSection.module.scss b/frontend/app/student/class/_components/AddClassSection/AddClassSection.module.scss new file mode 100644 index 00000000..b0b6d982 --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/AddClassSection.module.scss @@ -0,0 +1,27 @@ +.container { + width: 100%; + display: flex; + justify-content: flex-end; +} + +.iconContainer { + display: flex; + align-items: center; + gap: $spacing-xs; + transition: all 0.2s ease-in-out; + * { + color: $color-blue; + } + &:hover { + transform: scale(1.02); + cursor: pointer; + } + + &:active { + transform: scale(0.98); + } + p { + font-size: $font-size-md; + font-weight: $font-weight-medium; + } +} diff --git a/frontend/app/student/class/_components/AddClassSection/AddClassSection.tsx b/frontend/app/student/class/_components/AddClassSection/AddClassSection.tsx new file mode 100644 index 00000000..2a26e6c0 --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/AddClassSection.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React, { useState } from "react"; +import { CirclePlus } from "lucide-react"; +import styles from "./AddClassSection.module.scss"; +import EnterClassModal from "./EnterClassModal/EnterClassModal"; + +export default function AddClassSection() { + const [isOpen, setIsOpen] = useState(false); + const onClickAddClass = () => { + setIsOpen(true); + }; + return ( + <> +
+
+

클래스 추가하기

+ +
+
+ {isOpen && setIsOpen(false)} />} + + ); +} diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.module.scss b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.module.scss new file mode 100644 index 00000000..0e12b405 --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.module.scss @@ -0,0 +1,53 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: $spacing-md 0px; + background-color: white; + min-width: 300px; +} + +.title { + font-size: $font-size-xl; + font-weight: 600; + margin-bottom: $spacing-2xl; + text-align: center; +} + +.optionsContainer { + display: flex; + gap: $spacing-md; + width: 100%; + justify-content: center; +} + +.optionCard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 160px; + height: 160px; + border: 1px solid #e2e8f0; + border-radius: $radius-lg; + cursor: pointer; + transition: all 0.2s ease; + background-color: white; + + &:hover { + border-color: $color-blue; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1); + } +} + +.codeDisplay { + font-size: 32px; + font-weight: 700; + margin-bottom: 8px; +} + +.optionText { + font-size: 14px; + color: #374151; + text-align: center; +} diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx new file mode 100644 index 00000000..034d378a --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx @@ -0,0 +1,39 @@ +import ClosableModal from "@/components/Modal/ClosableModal/ClosableModal"; +import React from "react"; +import styles from "./EnterClassModal.module.scss"; +import { IMAGES } from "@/constants/images"; +import Image from "next/image"; + +type EnterClassModalProps = { + onClose: () => void; +}; + +export default function EnterClassModal({ onClose }: EnterClassModalProps) { + const handleCodeEntry = () => { + // 문자코드 입력 로직 + }; + + const handleQRScan = () => { + // QR 스캔 로직 + }; + + return ( + +
+

입장 방식을 선택하세요

+ +
+
+
12B3
+
문자코드 입력
+
+ +
+ QR Code +
QR 스캔
+
+
+
+
+ ); +} diff --git a/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx b/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx new file mode 100644 index 00000000..2a665083 --- /dev/null +++ b/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx @@ -0,0 +1,5 @@ +import React from "react"; + +export default function ClassListSection() { + return
ClassListSection
; +} diff --git a/frontend/app/student/class/page.tsx b/frontend/app/student/class/page.tsx index 4703c491..dcc5095d 100644 --- a/frontend/app/student/class/page.tsx +++ b/frontend/app/student/class/page.tsx @@ -1,3 +1,12 @@ +import VerticalTopContainer from "@/components/Container/VerticalTopContainer/VerticalTopContainer"; +import AddClassSection from "./_components/AddClassSection/AddClassSection"; +import ClassListSection from "./_components/ClassListSection/ClassListSection"; + export default function StudentClassPage() { - return
; + return ( + + + + + ); } From 62f02571f4c687e35b74a26ba019566334356db7 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sun, 3 Aug 2025 17:30:24 +0900 Subject: [PATCH 02/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=9E=85=EC=9E=A5=20=EB=AA=A8=EB=8B=AC=EC=97=90=20?= =?UTF-8?q?QR=20=EC=8A=A4=EC=BA=94=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CodeEntryModal/CodeEntryModal.module.scss | 32 ++++ .../CodeEntryModal/CodeEntryModal.tsx | 44 +++++ .../EnterClassModal/EnterClassModal.tsx | 27 +++- .../QRScanModal/QRScanModal.module.scss | 145 +++++++++++++++++ .../QRScanModal/QRScanModal.tsx | 151 ++++++++++++++++++ 5 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.module.scss create mode 100644 frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx create mode 100644 frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.module.scss create mode 100644 frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.module.scss b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.module.scss new file mode 100644 index 00000000..a783346a --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.module.scss @@ -0,0 +1,32 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: $spacing-md 0px; + background-color: white; + min-width: 300px; +} + +.title { + font-size: $font-size-xl; + font-weight: 600; + margin-bottom: $spacing-2xl; + text-align: center; +} + +.inputContainer { + width: 100%; + margin-bottom: $spacing-xl; +} + +.input { + width: 100%; +} + +.buttonContainer { + width: 100%; +} + +.confirmButton { + width: 100%; +} diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx new file mode 100644 index 00000000..be813229 --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx @@ -0,0 +1,44 @@ +import ClosableModal from "@/components/Modal/ClosableModal/ClosableModal"; +import React, { useState } from "react"; +import styles from "./CodeEntryModal.module.scss"; +import BasicInput from "@/components/Input/BasicInput/BasicInput"; +import FullWidthButton from "@/components/Button/FullWidthButton/FullWidthButton"; + +type CodeEntryModalProps = { + onClose: () => void; + onBack: () => void; +}; + +export default function CodeEntryModal({ + onClose, + onBack, +}: CodeEntryModalProps) { + const [entryCode, setEntryCode] = useState(""); + + const handleSubmit = () => { + // 입장코드 제출 로직 + console.log("입장코드 제출:", entryCode); + }; + + return ( + +
+

클래스 입장코드를 입력하세요.

+ +
+ setEntryCode(e.target.value)} + /> +
+ +
+ + 확인 + +
+
+
+ ); +} diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx index 034d378a..ab5f9317 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx @@ -1,22 +1,43 @@ import ClosableModal from "@/components/Modal/ClosableModal/ClosableModal"; -import React from "react"; +import React, { useState } from "react"; import styles from "./EnterClassModal.module.scss"; import { IMAGES } from "@/constants/images"; import Image from "next/image"; +import CodeEntryModal from "./CodeEntryModal/CodeEntryModal"; +import QRScanModal from "./QRScanModal/QRScanModal"; type EnterClassModalProps = { onClose: () => void; }; +type ModalState = "selection" | "codeEntry" | "qrScan"; + export default function EnterClassModal({ onClose }: EnterClassModalProps) { + const [modalState, setModalState] = useState("selection"); + const handleCodeEntry = () => { - // 문자코드 입력 로직 + setModalState("codeEntry"); }; const handleQRScan = () => { - // QR 스캔 로직 + setModalState("qrScan"); + }; + + const handleBack = () => { + setModalState("selection"); }; + // QR 스캔 모달이 활성화된 경우 + if (modalState === "qrScan") { + return ; + } + + // 문자코드 입력 모달이 활성화된 경우 + if (modalState === "codeEntry") { + return ; + } + + // 기본 선택 화면 return (
diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.module.scss b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.module.scss new file mode 100644 index 00000000..29df6fcd --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.module.scss @@ -0,0 +1,145 @@ +.container { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: #1a1a1a; + z-index: 1000; + display: flex; + flex-direction: column; +} + +.header { + padding: $spacing-lg; + text-align: center; + background-color: rgba(0, 0, 0, 0.2); +} + +.title { + color: white; + font-size: $font-size-lg; + font-weight: 600; + margin: 0; +} + +.scannerContainer { + flex: 1; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: white; +} + +.placeholderText { + margin-top: $spacing-lg; + font-size: $font-size-lg; + text-align: center; +} + +.overlayText { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + z-index: 10; + background-color: rgba(0, 0, 0, 0.3); + border-radius: $radius-md; + padding: $spacing-sm $spacing-md; + display: flex; + flex-direction: column; + align-items: center; + gap: $spacing-sm; + + p { + color: white; + font-size: $font-size-lg; + line-height: 1.5; + margin: 0; + border-radius: $radius-md; + } +} + +.retryButton { + margin-top: $spacing-md; + padding: $spacing-sm $spacing-lg; + background-color: $color-blue; + color: white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: darken($color-blue, 10%); + } +} + +.scannerFrame { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 300px; + height: 300px; + border: 2px solid transparent; + z-index: 5; +} + +.corner { + position: absolute; + width: 30px; + height: 30px; + border: 3px solid white; +} + +.corner:nth-child(1) { + border-right: none; + border-bottom: none; +} + +.corner:nth-child(2) { + border-left: none; + border-bottom: none; +} + +.corner:nth-child(3) { + border-right: none; + border-top: none; +} + +.corner:nth-child(4) { + border-left: none; + border-top: none; +} + +.backButton { + display: flex; + align-items: center; + gap: $spacing-xs; + background-color: rgba(255, 255, 255, 0.1); + color: white; + padding: $spacing-sm $spacing-lg; + cursor: pointer; + font-size: $font-size-sm; + + &:hover { + background-color: rgba(255, 255, 255, 0.3); + } +} diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx new file mode 100644 index 00000000..8e101586 --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useRef, useState } from "react"; +import styles from "./QRScanModal.module.scss"; +import { IMAGES } from "@/constants/images"; +import Image from "next/image"; +import { ArrowLeft, Camera } from "lucide-react"; + +type QRScanModalProps = { + onBack: () => void; +}; + +export default function QRScanModal({ onBack }: QRScanModalProps) { + const videoRef = useRef(null); + const [isScanning, setIsScanning] = useState(false); + const [cameraError, setCameraError] = useState(null); + + // 데스크탑과 모바일 환경 감지 + const isMobile = () => { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent + ); + }; + + const startCamera = async () => { + try { + setCameraError(null); + + // 데스크탑에서는 전면 카메라, 모바일에서는 후면 카메라 사용 + const facingMode = isMobile() ? "environment" : "user"; + + // getUserMedia로 카메라 스트림 획득 + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: facingMode, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + + // video 요소에 스트림 할당 + if (videoRef.current) { + videoRef.current.srcObject = stream; + + // 비디오 로드 완료 후 재생 + await new Promise((resolve, reject) => { + if (videoRef.current) { + videoRef.current.onloadedmetadata = () => { + resolve(true); + }; + videoRef.current.onerror = () => { + reject(new Error("비디오 로드 실패")); + }; + } + }); + + // 안전한 재생 시도 + try { + await videoRef.current.play(); + } catch (playError) { + console.warn("자동 재생 실패, 사용자 상호작용 필요:", playError); + // 자동 재생이 실패해도 스트림은 할당되어 있으므로 계속 진행 + } + + setIsScanning(true); + } else { + throw new Error("video 요소를 찾을 수 없습니다"); + } + } catch (error) { + console.error("카메라 접근 오류:", error); + setCameraError( + "카메라에 접근할 수 없습니다. 브라우저 설정을 확인해주세요." + ); + setIsScanning(false); + } + }; + + const stopCamera = () => { + if (videoRef.current && videoRef.current.srcObject) { + const stream = videoRef.current.srcObject as MediaStream; + stream.getTracks().forEach((track) => { + track.stop(); + }); + } + }; + + useEffect(() => { + startCamera(); + + return () => { + stopCamera(); + }; + }, []); + + return ( +
+
+ +

뒤로가기

+
+
+ {/* video 요소를 항상 렌더링 */} +
+
+ ); +} From 39f04626b4dff9a002390f9afd5c9a1efb12d0a2 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sun, 3 Aug 2025 17:34:11 +0900 Subject: [PATCH 03/45] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(#267)=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=9E=85=EC=9E=A5=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20onBack=20=ED=94=84=EB=A1=9C=ED=8D=BC?= =?UTF-8?q?=ED=8B=B0=EB=A5=BC=20onClose=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EnterClassModal/CodeEntryModal/CodeEntryModal.tsx | 6 +----- .../EnterClassModal/EnterClassModal.tsx | 8 ++------ .../EnterClassModal/QRScanModal/QRScanModal.tsx | 11 +++++------ 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx index be813229..2ba49a08 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx @@ -6,13 +6,9 @@ import FullWidthButton from "@/components/Button/FullWidthButton/FullWidthButton type CodeEntryModalProps = { onClose: () => void; - onBack: () => void; }; -export default function CodeEntryModal({ - onClose, - onBack, -}: CodeEntryModalProps) { +export default function CodeEntryModal({ onClose }: CodeEntryModalProps) { const [entryCode, setEntryCode] = useState(""); const handleSubmit = () => { diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx index ab5f9317..66cfa623 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/EnterClassModal.tsx @@ -23,18 +23,14 @@ export default function EnterClassModal({ onClose }: EnterClassModalProps) { setModalState("qrScan"); }; - const handleBack = () => { - setModalState("selection"); - }; - // QR 스캔 모달이 활성화된 경우 if (modalState === "qrScan") { - return ; + return ; } // 문자코드 입력 모달이 활성화된 경우 if (modalState === "codeEntry") { - return ; + return ; } // 기본 선택 화면 diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx index 8e101586..6a3ed9e4 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx @@ -2,13 +2,13 @@ import React, { useEffect, useRef, useState } from "react"; import styles from "./QRScanModal.module.scss"; import { IMAGES } from "@/constants/images"; import Image from "next/image"; -import { ArrowLeft, Camera } from "lucide-react"; +import { Camera, X } from "lucide-react"; type QRScanModalProps = { - onBack: () => void; + onClose: () => void; }; -export default function QRScanModal({ onBack }: QRScanModalProps) { +export default function QRScanModal({ onClose }: QRScanModalProps) { const videoRef = useRef(null); const [isScanning, setIsScanning] = useState(false); const [cameraError, setCameraError] = useState(null); @@ -92,9 +92,8 @@ export default function QRScanModal({ onBack }: QRScanModalProps) { return (
-
- -

뒤로가기

+
+
{/* video 요소를 항상 렌더링 */} From 0ecedf71ede53b95c56e056cd9a903aa6cd4801b Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sun, 3 Aug 2025 17:45:33 +0900 Subject: [PATCH 04/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=EC=9E=85=EC=9E=A5?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=9E=85=EB=A0=A5=20API=20=EB=B0=8F=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/api/classes/inputEntryCode.ts | 20 ++++++++++++++++++++ frontend/constants/endpoints.ts | 1 + frontend/types/classes/inputEntryCode.ts | 3 +++ 3 files changed, 24 insertions(+) create mode 100644 frontend/api/classes/inputEntryCode.ts create mode 100644 frontend/types/classes/inputEntryCode.ts diff --git a/frontend/api/classes/inputEntryCode.ts b/frontend/api/classes/inputEntryCode.ts new file mode 100644 index 00000000..2e96be53 --- /dev/null +++ b/frontend/api/classes/inputEntryCode.ts @@ -0,0 +1,20 @@ +import axios from "axios"; +import { axiosInstance } from "@/api/axiosInstance"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; +import { InputEntryCodeResult } from "@/types/classes/inputEntryCode"; + +export async function inputEntryCode({ entryCode }: { entryCode: string }) { + try { + const response = await axiosInstance.post< + ApiResponse + >(ENDPOINTS.CLASSES.INPUT_ENTRY_CODE, { entryCode }); + + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response) { + return error.response.data as ApiResponse; + } + throw error; + } +} diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index 0c9accf0..31167417 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -38,6 +38,7 @@ export const ENDPOINTS = { UPDATE: (classId: string) => `${BASE_API}/classes/${classId}`, GET_DETAIL: (classId: string) => `${BASE_API}/classes/${classId}`, DELETE: (classId: string) => `${BASE_API}/classes/${classId}`, + INPUT_ENTRY_CODE: `${BASE_API}/classes/code/verify`, GET_LECTURES: (classId: string) => `${BASE_API}/classes/${classId}/lectures`, diff --git a/frontend/types/classes/inputEntryCode.ts b/frontend/types/classes/inputEntryCode.ts new file mode 100644 index 00000000..dfc7e6c1 --- /dev/null +++ b/frontend/types/classes/inputEntryCode.ts @@ -0,0 +1,3 @@ +export type InputEntryCodeResult = { + classId: string; +}; From 301c3221fb54a2e0ee588d46c7de2b90434e7976 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sun, 3 Aug 2025 17:55:52 +0900 Subject: [PATCH 05/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=9E=85=EC=9E=A5=20=EB=AA=A8=EB=8B=AC=EC=97=90=20?= =?UTF-8?q?=EC=9E=85=EC=9E=A5=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EC=B6=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CodeEntryModal/CodeEntryModal.tsx | 18 ++- .../QRScanModal/QRScanModal.tsx | 111 +++++++++--------- 2 files changed, 68 insertions(+), 61 deletions(-) diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx index 2ba49a08..beaad4d5 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx @@ -3,6 +3,8 @@ import React, { useState } from "react"; import styles from "./CodeEntryModal.module.scss"; import BasicInput from "@/components/Input/BasicInput/BasicInput"; import FullWidthButton from "@/components/Button/FullWidthButton/FullWidthButton"; +import { inputEntryCode } from "@/api/classes/inputEntryCode"; +import AlertModal from "@/components/Modal/AlertModal/AlertModal"; type CodeEntryModalProps = { onClose: () => void; @@ -10,10 +12,15 @@ type CodeEntryModalProps = { export default function CodeEntryModal({ onClose }: CodeEntryModalProps) { const [entryCode, setEntryCode] = useState(""); + const [isAlertModalOpen, setIsAlertModalOpen] = useState(false); - const handleSubmit = () => { - // 입장코드 제출 로직 - console.log("입장코드 제출:", entryCode); + const handleSubmit = async () => { + const response = await inputEntryCode({ entryCode }); + if (response.isSuccess) { + onClose(); + } else { + setIsAlertModalOpen(true); + } }; return ( @@ -35,6 +42,11 @@ export default function CodeEntryModal({ onClose }: CodeEntryModalProps) {
+ {isAlertModalOpen && ( + setIsAlertModalOpen(false)}> +

입장코드가 일치하지 않습니다.

+
+ )} ); } diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx index 6a3ed9e4..c529ed29 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx @@ -20,59 +20,6 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { ); }; - const startCamera = async () => { - try { - setCameraError(null); - - // 데스크탑에서는 전면 카메라, 모바일에서는 후면 카메라 사용 - const facingMode = isMobile() ? "environment" : "user"; - - // getUserMedia로 카메라 스트림 획득 - const stream = await navigator.mediaDevices.getUserMedia({ - video: { - facingMode: facingMode, - width: { ideal: 1280 }, - height: { ideal: 720 }, - }, - }); - - // video 요소에 스트림 할당 - if (videoRef.current) { - videoRef.current.srcObject = stream; - - // 비디오 로드 완료 후 재생 - await new Promise((resolve, reject) => { - if (videoRef.current) { - videoRef.current.onloadedmetadata = () => { - resolve(true); - }; - videoRef.current.onerror = () => { - reject(new Error("비디오 로드 실패")); - }; - } - }); - - // 안전한 재생 시도 - try { - await videoRef.current.play(); - } catch (playError) { - console.warn("자동 재생 실패, 사용자 상호작용 필요:", playError); - // 자동 재생이 실패해도 스트림은 할당되어 있으므로 계속 진행 - } - - setIsScanning(true); - } else { - throw new Error("video 요소를 찾을 수 없습니다"); - } - } catch (error) { - console.error("카메라 접근 오류:", error); - setCameraError( - "카메라에 접근할 수 없습니다. 브라우저 설정을 확인해주세요." - ); - setIsScanning(false); - } - }; - const stopCamera = () => { if (videoRef.current && videoRef.current.srcObject) { const stream = videoRef.current.srcObject as MediaStream; @@ -83,6 +30,59 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { }; useEffect(() => { + const startCamera = async () => { + try { + setCameraError(null); + + // 데스크탑에서는 전면 카메라, 모바일에서는 후면 카메라 사용 + const facingMode = isMobile() ? "environment" : "user"; + + // getUserMedia로 카메라 스트림 획득 + const stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: facingMode, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + + // video 요소에 스트림 할당 + if (videoRef.current) { + videoRef.current.srcObject = stream; + + // 비디오 로드 완료 후 재생 + await new Promise((resolve, reject) => { + if (videoRef.current) { + videoRef.current.onloadedmetadata = () => { + resolve(true); + }; + videoRef.current.onerror = () => { + reject(new Error("비디오 로드 실패")); + }; + } + }); + + // 안전한 재생 시도 + try { + await videoRef.current.play(); + } catch (playError) { + console.warn("자동 재생 실패, 사용자 상호작용 필요:", playError); + // 자동 재생이 실패해도 스트림은 할당되어 있으므로 계속 진행 + } + + setIsScanning(true); + } else { + throw new Error("video 요소를 찾을 수 없습니다"); + } + } catch (error) { + console.error("카메라 접근 오류:", error); + setCameraError( + "카메라에 접근할 수 없습니다. 브라우저 설정을 확인해주세요." + ); + setIsScanning(false); + } + }; + startCamera(); return () => { @@ -118,11 +118,6 @@ export default function QRScanModal({ onClose }: QRScanModalProps) {

{cameraError || "QR 코드를 프레임 안에 맞춰주세요"}

- {cameraError && ( - - )}
)} From cbdf9a33143aa576ab63b072dd3db2a1d13542ad Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sun, 3 Aug 2025 18:06:35 +0900 Subject: [PATCH 06/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SetClassNicknameModal.module.scss | 0 .../SetClassNicknameModal.tsx | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.module.scss create mode 100644 frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx diff --git a/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.module.scss b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.module.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx new file mode 100644 index 00000000..ab442ba6 --- /dev/null +++ b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx @@ -0,0 +1,30 @@ +import FullWidthButton from "@/components/Button/FullWidthButton/FullWidthButton"; +import ClosableModal from "@/components/Modal/ClosableModal/ClosableModal"; +import { ROUTES } from "@/constants/routes"; +import router from "next/router"; +import styles from "./SetClassNicknameModal.module.scss"; + +type SetClassNicknameModalProps = { + onClose: () => void; + classId: string; +}; + +export default function SetClassNicknameModal({ + onClose, + classId, +}: SetClassNicknameModalProps) { + return ( + +
+

클래스 닉네임을 설정하세요.

+
+ { + router.push(ROUTES.studentClassDetail(classId)); + }} + > + 확인 + +
+ ); +} From b57b5ea7a187b7ff6f0559221a0bca55821807df Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Sun, 3 Aug 2025 18:21:37 +0900 Subject: [PATCH 07/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=9E=85=EC=9E=A5=20=EB=AA=A8=EB=8B=AC=EC=97=90=20?= =?UTF-8?q?QR=20=EC=BD=94=EB=93=9C=20=EC=8A=A4=EC=BA=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20jsqr=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CodeEntryModal/CodeEntryModal.tsx | 13 ++- .../QRScanModal/QRScanModal.tsx | 89 ++++++++++++++++++- frontend/package-lock.json | 7 ++ frontend/package.json | 1 + 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx index beaad4d5..52f49fbb 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx @@ -5,6 +5,7 @@ import BasicInput from "@/components/Input/BasicInput/BasicInput"; import FullWidthButton from "@/components/Button/FullWidthButton/FullWidthButton"; import { inputEntryCode } from "@/api/classes/inputEntryCode"; import AlertModal from "@/components/Modal/AlertModal/AlertModal"; +import SetClassNicknameModal from "../../SetClassNicknameModal/SetClassNicknameModal"; type CodeEntryModalProps = { onClose: () => void; @@ -13,11 +14,15 @@ type CodeEntryModalProps = { export default function CodeEntryModal({ onClose }: CodeEntryModalProps) { const [entryCode, setEntryCode] = useState(""); const [isAlertModalOpen, setIsAlertModalOpen] = useState(false); + const [isSetClassNicknameModalOpen, setIsSetClassNicknameModalOpen] = + useState(false); + const [classId, setClassId] = useState(""); const handleSubmit = async () => { const response = await inputEntryCode({ entryCode }); if (response.isSuccess) { - onClose(); + setClassId(response.result?.classId || ""); + setIsSetClassNicknameModalOpen(true); } else { setIsAlertModalOpen(true); } @@ -47,6 +52,12 @@ export default function CodeEntryModal({ onClose }: CodeEntryModalProps) {

입장코드가 일치하지 않습니다.

)} + {isSetClassNicknameModalOpen && ( + setIsSetClassNicknameModalOpen(false)} + classId={classId} + /> + )} ); } diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx index c529ed29..4627ce68 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx @@ -3,6 +3,10 @@ import styles from "./QRScanModal.module.scss"; import { IMAGES } from "@/constants/images"; import Image from "next/image"; import { Camera, X } from "lucide-react"; +import { inputEntryCode } from "@/api/classes/inputEntryCode"; +import AlertModal from "@/components/Modal/AlertModal/AlertModal"; +import SetClassNicknameModal from "../../SetClassNicknameModal/SetClassNicknameModal"; +import jsQR from "jsqr"; type QRScanModalProps = { onClose: () => void; @@ -10,8 +14,14 @@ type QRScanModalProps = { export default function QRScanModal({ onClose }: QRScanModalProps) { const videoRef = useRef(null); + const canvasRef = useRef(null); const [isScanning, setIsScanning] = useState(false); const [cameraError, setCameraError] = useState(null); + const [isAlertModalOpen, setIsAlertModalOpen] = useState(false); + const [isSetClassNicknameModalOpen, setIsSetClassNicknameModalOpen] = + useState(false); + const [classId, setClassId] = useState(""); + const scanIntervalRef = useRef(null); // 데스크탑과 모바일 환경 감지 const isMobile = () => { @@ -27,6 +37,28 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { track.stop(); }); } + if (scanIntervalRef.current) { + clearInterval(scanIntervalRef.current); + scanIntervalRef.current = null; + } + }; + + // QR 코드 감지 처리 + const handleQRCodeDetected = async (entryCode: string) => { + try { + const response = await inputEntryCode({ entryCode }); + + if (response.isSuccess) { + setClassId(response.result?.classId || ""); + setIsSetClassNicknameModalOpen(true); + stopCamera(); + } else { + setIsAlertModalOpen(true); + } + } catch (error) { + console.error("QR 코드 처리 오류:", error); + setIsAlertModalOpen(true); + } }; useEffect(() => { @@ -71,6 +103,9 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { } setIsScanning(true); + + // QR 코드 스캔 시작 (100ms마다) + scanIntervalRef.current = setInterval(scanQRCode, 100); } else { throw new Error("video 요소를 찾을 수 없습니다"); } @@ -83,6 +118,34 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { } }; + // QR 코드 스캔 함수 + const scanQRCode = () => { + if (!videoRef.current || !canvasRef.current) return; + + const video = videoRef.current; + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d"); + + if (!ctx) return; + + // 비디오 크기에 맞춰 캔버스 크기 설정 + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + // 비디오 프레임을 캔버스에 그리기 + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + // 캔버스에서 이미지 데이터 추출 + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + + // jsQR로 QR 코드 감지 + const code = jsQR(imageData.data, imageData.width, imageData.height); + + if (code) { + handleQRCodeDetected(code.data); + } + }; + startCamera(); return () => { @@ -92,7 +155,13 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { return (
-
+
{ + stopCamera(); + onClose(); + }} + >
@@ -106,6 +175,9 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { style={{ display: isScanning && !cameraError ? "block" : "none" }} /> + {/* 숨겨진 캔버스 (QR 코드 스캔용) */} + + {/* 카메라가 작동하지 않을 때만 placeholder 표시 */} {(!isScanning || cameraError) && (
@@ -140,6 +212,21 @@ export default function QRScanModal({ onClose }: QRScanModalProps) {
)}
+ + {/* 알림 모달 */} + {isAlertModalOpen && ( + setIsAlertModalOpen(false)}> +

입장코드가 일치하지 않습니다.

+
+ )} + + {/* 클래스 닉네임 설정 모달 */} + {isSetClassNicknameModalOpen && ( + setIsSetClassNicknameModalOpen(false)} + classId={classId} + /> + )}
); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 346ee1e8..0dfd414b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "dayjs": "^1.11.13", "flatpickr": "^4.6.13", "jsonwebtoken": "^9.0.2", + "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lucide-react": "^0.487.0", "next": "15.2.4", @@ -7146,6 +7147,12 @@ "npm": ">=6" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index b41bbbb3..fc32d8b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "dayjs": "^1.11.13", "flatpickr": "^4.6.13", "jsonwebtoken": "^9.0.2", + "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lucide-react": "^0.487.0", "next": "15.2.4", From d1a2a2a5bac40afeece19e78deeb698e42cb3aac Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Mon, 4 Aug 2025 23:12:06 +0900 Subject: [PATCH 08/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EB=B0=8F=20QR=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/api/classes/fetchMyClassList.ts | 2 +- .../CodeEntryModal/CodeEntryModal.tsx | 4 +- .../QRScanModal/QRScanModal.tsx | 49 +++--- .../SetClassNicknameModal.module.scss | 88 ++++++++++ .../SetClassNicknameModal.tsx | 151 +++++++++++++++++- 5 files changed, 265 insertions(+), 29 deletions(-) diff --git a/frontend/api/classes/fetchMyClassList.ts b/frontend/api/classes/fetchMyClassList.ts index da9100db..337efbb2 100644 --- a/frontend/api/classes/fetchMyClassList.ts +++ b/frontend/api/classes/fetchMyClassList.ts @@ -12,7 +12,7 @@ export async function fetchMyClassList() { return response.data; } catch (error: unknown) { if (axios.isAxiosError(error) && error.response) { - return error.response.data as ApiResponse; + return error.response.data; } throw error; } diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx index 52f49fbb..7fb1a78a 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/CodeEntryModal/CodeEntryModal.tsx @@ -14,6 +14,7 @@ type CodeEntryModalProps = { export default function CodeEntryModal({ onClose }: CodeEntryModalProps) { const [entryCode, setEntryCode] = useState(""); const [isAlertModalOpen, setIsAlertModalOpen] = useState(false); + const [alertMessage, setAlertMessage] = useState(""); const [isSetClassNicknameModalOpen, setIsSetClassNicknameModalOpen] = useState(false); const [classId, setClassId] = useState(""); @@ -24,6 +25,7 @@ export default function CodeEntryModal({ onClose }: CodeEntryModalProps) { setClassId(response.result?.classId || ""); setIsSetClassNicknameModalOpen(true); } else { + setAlertMessage(response.message || "입장코드가 일치하지 않습니다."); setIsAlertModalOpen(true); } }; @@ -49,7 +51,7 @@ export default function CodeEntryModal({ onClose }: CodeEntryModalProps) {
{isAlertModalOpen && ( setIsAlertModalOpen(false)}> -

입장코드가 일치하지 않습니다.

+

{alertMessage}

)} {isSetClassNicknameModalOpen && ( diff --git a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx index 4627ce68..e95ca5ac 100644 --- a/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/EnterClassModal/QRScanModal/QRScanModal.tsx @@ -21,7 +21,9 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { const [isSetClassNicknameModalOpen, setIsSetClassNicknameModalOpen] = useState(false); const [classId, setClassId] = useState(""); + const [alertMessage, setAlertMessage] = useState(""); const scanIntervalRef = useRef(null); + const isProcessingRef = useRef(false); // 데스크탑과 모바일 환경 감지 const isMobile = () => { @@ -43,24 +45,6 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { } }; - // QR 코드 감지 처리 - const handleQRCodeDetected = async (entryCode: string) => { - try { - const response = await inputEntryCode({ entryCode }); - - if (response.isSuccess) { - setClassId(response.result?.classId || ""); - setIsSetClassNicknameModalOpen(true); - stopCamera(); - } else { - setIsAlertModalOpen(true); - } - } catch (error) { - console.error("QR 코드 처리 오류:", error); - setIsAlertModalOpen(true); - } - }; - useEffect(() => { const startCamera = async () => { try { @@ -118,6 +102,33 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { } }; + // QR 코드 감지 처리 + const handleQRCodeDetected = async (entryCode: string) => { + if (isProcessingRef.current) return; // 중복 처리 방지 + + try { + isProcessingRef.current = true; + const response = await inputEntryCode({ entryCode }); + + if (response.isSuccess) { + setClassId(response.result?.classId || ""); + setIsSetClassNicknameModalOpen(true); + stopCamera(); + } else { + setAlertMessage( + response.message || "입장 코드가 올바르지 않거나 만료되었습니다." + ); + setIsAlertModalOpen(true); + } + } catch (error) { + console.error("QR 코드 처리 오류:", error); + setAlertMessage("입장 코드가 올바르지 않거나 만료되었습니다."); + setIsAlertModalOpen(true); + } finally { + isProcessingRef.current = false; + } + }; + // QR 코드 스캔 함수 const scanQRCode = () => { if (!videoRef.current || !canvasRef.current) return; @@ -216,7 +227,7 @@ export default function QRScanModal({ onClose }: QRScanModalProps) { {/* 알림 모달 */} {isAlertModalOpen && ( setIsAlertModalOpen(false)}> -

입장코드가 일치하지 않습니다.

+

{alertMessage}

)} diff --git a/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.module.scss b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.module.scss index e69de29b..b207ca4f 100644 --- a/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.module.scss +++ b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.module.scss @@ -0,0 +1,88 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + background-color: white; + padding-top: $spacing-xl; +} + +.loading { + text-align: center; + padding: $spacing-xl; + + p { + font-size: $font-size-md; + } +} + +.error { + text-align: center; + padding: $spacing-xl; + + p { + font-size: $font-size-md; + } +} + +.classInfoCard { + width: 100%; + background-color: white; + border: 1px solid $color-neutral-7; + border-radius: $radius-lg; + padding: $spacing-md; + margin-bottom: $spacing-xl; + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.1); +} + +.className { + font-size: $font-size-lg; + font-weight: $font-weight-medium; + margin-bottom: $spacing-md; + border-bottom: 0.5px solid $color-mutedblue; + padding-bottom: $spacing-md; + text-align: left; +} + +.classDetails { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.detailItem { + color: $color-mutedblue; + display: flex; + align-items: center; + gap: $spacing-sm; + + font-size: $font-size-sm; + + svg { + flex-shrink: 0; + } + + span { + line-height: 1.4; + } +} + +.nicknameSection { + width: 100%; + margin-bottom: $spacing-xl; +} + +.nicknameLabel { + font-size: $font-size-md; + margin-bottom: $spacing-md; + text-align: center; + line-height: 1.5; +} + +.inputContainer { + width: 100%; + margin-bottom: $spacing-lg; +} + +.buttonContainer { + width: 100%; +} diff --git a/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx index ab442ba6..4ddf45e3 100644 --- a/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx @@ -3,28 +3,163 @@ import ClosableModal from "@/components/Modal/ClosableModal/ClosableModal"; import { ROUTES } from "@/constants/routes"; import router from "next/router"; import styles from "./SetClassNicknameModal.module.scss"; +import BasicInput from "@/components/Input/BasicInput/BasicInput"; +import { useState, useEffect } from "react"; +import AlertModal from "@/components/Modal/AlertModal/AlertModal"; +import { fetchClassInfoByClassId } from "@/api/classes/fetchClassInfoByClassId"; +import { Clock, Calendar } from "lucide-react"; +import { FetchClassInfoByClassIdResult } from "@/types/classes/fetchClassInfoByClassIdTypes"; type SetClassNicknameModalProps = { onClose: () => void; classId: string; }; +// 임시 API 함수 (실제 API가 구현되면 교체) +const setClassNickname = async ({ + classId, + nickname, +}: { + classId: string; + nickname: string; +}) => { + // 실제 API 호출로 교체 필요 + console.log("닉네임 설정:", { classId, nickname }); + return { isSuccess: true, message: "닉네임이 설정되었습니다." }; +}; + export default function SetClassNicknameModal({ onClose, classId, }: SetClassNicknameModalProps) { + const [nickname, setNickname] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isAlertModalOpen, setIsAlertModalOpen] = useState(false); + const [alertMessage, setAlertMessage] = useState(""); + const [classInfo, setClassInfo] = + useState(null); + const [isLoadingClassInfo, setIsLoadingClassInfo] = useState(true); + + // 클래스 정보 가져오기 + useEffect(() => { + const getClassInfo = async () => { + try { + setIsLoadingClassInfo(true); + const response = await fetchClassInfoByClassId(classId); + + if (response.isSuccess) { + setClassInfo(response.result || null); + } else { + setAlertMessage( + response.message || "클래스 정보를 불러오는데 실패했습니다." + ); + setIsAlertModalOpen(true); + } + } catch (error) { + console.error("클래스 정보 로드 오류:", error); + setAlertMessage("클래스 정보를 불러오는 중 오류가 발생했습니다."); + setIsAlertModalOpen(true); + } finally { + setIsLoadingClassInfo(false); + } + }; + + getClassInfo(); + }, [classId]); + + const handleSubmit = async () => { + if (!nickname.trim()) { + setAlertMessage("닉네임을 입력해주세요."); + setIsAlertModalOpen(true); + return; + } + + try { + setIsLoading(true); + const response = await setClassNickname({ classId, nickname }); + + if (response.isSuccess) { + // 성공 시 클래스 상세 페이지로 이동 + router.push(ROUTES.studentClassDetail(classId)); + } else { + setAlertMessage(response.message || "닉네임 설정에 실패했습니다."); + setIsAlertModalOpen(true); + } + } catch (error) { + console.error("닉네임 설정 오류:", error); + setAlertMessage("닉네임 설정에 실패했습니다."); + setIsAlertModalOpen(true); + } finally { + setIsLoading(false); + } + }; + return (
-

클래스 닉네임을 설정하세요.

+ {isLoadingClassInfo ? ( +
+

클래스 정보를 불러오는 중...

+
+ ) : classInfo ? ( + <> + {/* 클래스 정보 카드 */} +
+

+ {classInfo.className} {classInfo.professorName} +

+ +
+
+ + 월 (10:15~11:45)/수 (12:00~13:15) +
+ +
+ + 2024.03.04~2025.06.13 +
+
+
+ + {/* 닉네임 입력 섹션 */} +
+

+ 해당 클래스에서 사용할 닉네임을 입력해주세요 +

+ +
+ setNickname(e.target.value)} + /> +
+
+ + {/* 클래스 입장 버튼 */} +
+ + {isLoading ? "입장 중..." : "클래스 입장"} + +
+ + ) : ( +
+

클래스 정보를 불러올 수 없습니다.

+
+ )}
- { - router.push(ROUTES.studentClassDetail(classId)); - }} - > - 확인 - + + {/* 알림 모달 */} + {isAlertModalOpen && ( + setIsAlertModalOpen(false)}> +

{alertMessage}

+
+ )}
); } From 9a5441830126ed69a6ac5daf50082caaf30a05a2 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Mon, 4 Aug 2025 23:21:09 +0900 Subject: [PATCH 09/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?API=20=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80,=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EC=97=90=EC=84=9C=20API=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/student-classes/setClassNickname.ts | 28 +++++++++++++++++++ .../SetClassNicknameModal.tsx | 16 ++--------- frontend/constants/endpoints.ts | 1 + .../student-classes/setClassNicknameTypes.ts | 4 +++ 4 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 frontend/api/student-classes/setClassNickname.ts create mode 100644 frontend/types/student-classes/setClassNicknameTypes.ts diff --git a/frontend/api/student-classes/setClassNickname.ts b/frontend/api/student-classes/setClassNickname.ts new file mode 100644 index 00000000..b91db894 --- /dev/null +++ b/frontend/api/student-classes/setClassNickname.ts @@ -0,0 +1,28 @@ +import { axiosInstance } from "@/api/axiosInstance"; +import axios from "axios"; +import { ENDPOINTS } from "@/constants/endpoints"; +import { ApiResponse } from "@/types/apiResponseTypes"; + +export async function setClassNickname({ + classId, + nickname, +}: { + classId: string; + nickname: string; +}) { + try { + const response = await axiosInstance.post>( + ENDPOINTS.STUDENT_CLASSES.SET_CLASS_NICKNAME, + { + classId, + classNickname: nickname, + } + ); + 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/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx index 4ddf45e3..2e92990e 100644 --- a/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx +++ b/frontend/app/student/class/_components/AddClassSection/SetClassNicknameModal/SetClassNicknameModal.tsx @@ -1,3 +1,4 @@ +"use client"; import FullWidthButton from "@/components/Button/FullWidthButton/FullWidthButton"; import ClosableModal from "@/components/Modal/ClosableModal/ClosableModal"; import { ROUTES } from "@/constants/routes"; @@ -9,25 +10,13 @@ import AlertModal from "@/components/Modal/AlertModal/AlertModal"; import { fetchClassInfoByClassId } from "@/api/classes/fetchClassInfoByClassId"; import { Clock, Calendar } from "lucide-react"; import { FetchClassInfoByClassIdResult } from "@/types/classes/fetchClassInfoByClassIdTypes"; +import { setClassNickname } from "@/api/student-classes/setClassNickname"; type SetClassNicknameModalProps = { onClose: () => void; classId: string; }; -// 임시 API 함수 (실제 API가 구현되면 교체) -const setClassNickname = async ({ - classId, - nickname, -}: { - classId: string; - nickname: string; -}) => { - // 실제 API 호출로 교체 필요 - console.log("닉네임 설정:", { classId, nickname }); - return { isSuccess: true, message: "닉네임이 설정되었습니다." }; -}; - export default function SetClassNicknameModal({ onClose, classId, @@ -79,7 +68,6 @@ export default function SetClassNicknameModal({ const response = await setClassNickname({ classId, nickname }); if (response.isSuccess) { - // 성공 시 클래스 상세 페이지로 이동 router.push(ROUTES.studentClassDetail(classId)); } else { setAlertMessage(response.message || "닉네임 설정에 실패했습니다."); diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index 31167417..70575712 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -53,6 +53,7 @@ export const ENDPOINTS = { STUDENT_CLASSES: { GET_STUDENTS: (classId: string) => `${BASE_API}/student-classes/${classId}/students`, + SET_CLASS_NICKNAME: `${BASE_API}/student-classes/create`, }, // 강의 관련 diff --git a/frontend/types/student-classes/setClassNicknameTypes.ts b/frontend/types/student-classes/setClassNicknameTypes.ts new file mode 100644 index 00000000..4c0cdf89 --- /dev/null +++ b/frontend/types/student-classes/setClassNicknameTypes.ts @@ -0,0 +1,4 @@ +export interface SetClassNicknameRequest { + classId: string; + classNickname: string; +} From d0027b928ae40c4b8b00d2364471df382e032e72 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Mon, 4 Aug 2025 23:27:24 +0900 Subject: [PATCH 10/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=EB=82=B4=20?= =?UTF-8?q?=EC=88=98=EC=97=85=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EB=8A=94=20API=20=EB=B0=8F=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/student-classes/fetchMyClassList.ts | 19 +++++++++++++++++++ frontend/constants/endpoints.ts | 1 + .../student-classes/fetchMyClassListTypes.ts | 8 ++++++++ 3 files changed, 28 insertions(+) create mode 100644 frontend/api/student-classes/fetchMyClassList.ts create mode 100644 frontend/types/student-classes/fetchMyClassListTypes.ts diff --git a/frontend/api/student-classes/fetchMyClassList.ts b/frontend/api/student-classes/fetchMyClassList.ts new file mode 100644 index 00000000..f59b3d03 --- /dev/null +++ b/frontend/api/student-classes/fetchMyClassList.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 { FetchMyClassListResult } from "@/types/classes/fetchMyClassListTypes"; + +export async function fetchMyClassList() { + try { + const response = await axiosInstance.get< + ApiResponse + >(ENDPOINTS.STUDENT_CLASSES.GET_MY_CLASSES); + return response.data; + } catch (error: unknown) { + if (axios.isAxiosError(error) && error.response) { + return error.response.data as ApiResponse; + } + throw error; + } +} diff --git a/frontend/constants/endpoints.ts b/frontend/constants/endpoints.ts index 70575712..2a66920f 100644 --- a/frontend/constants/endpoints.ts +++ b/frontend/constants/endpoints.ts @@ -54,6 +54,7 @@ export const ENDPOINTS = { GET_STUDENTS: (classId: string) => `${BASE_API}/student-classes/${classId}/students`, SET_CLASS_NICKNAME: `${BASE_API}/student-classes/create`, + GET_MY_CLASSES: `${BASE_API}/student-classes`, }, // 강의 관련 diff --git a/frontend/types/student-classes/fetchMyClassListTypes.ts b/frontend/types/student-classes/fetchMyClassListTypes.ts new file mode 100644 index 00000000..65ad7922 --- /dev/null +++ b/frontend/types/student-classes/fetchMyClassListTypes.ts @@ -0,0 +1,8 @@ +export interface FetchMyClassListResult { + classId: string; + className: string; + startDate: string; + endDate: string; + classDate: string; + professorName: string; +} From bb604ec84f5b923ed588a45f958e84f6604df182 Mon Sep 17 00:00:00 2001 From: Son Ahyun Date: Mon, 4 Aug 2025 23:34:10 +0900 Subject: [PATCH 11/45] =?UTF-8?q?=E2=9C=A8=20(#267)=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=EC=A4=91=EC=9D=B8=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=ED=91=9C=EC=8B=9C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClassListSection.module.scss | 114 ++++++++++++++++++ .../ClassListSection/ClassListSection.tsx | 109 ++++++++++++++++- 2 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 frontend/app/student/class/_components/ClassListSection/ClassListSection.module.scss diff --git a/frontend/app/student/class/_components/ClassListSection/ClassListSection.module.scss b/frontend/app/student/class/_components/ClassListSection/ClassListSection.module.scss new file mode 100644 index 00000000..e578148e --- /dev/null +++ b/frontend/app/student/class/_components/ClassListSection/ClassListSection.module.scss @@ -0,0 +1,114 @@ +.container { + width: 100%; +} + +.loading { + text-align: center; + padding: $spacing-2xl; + + p { + color: $color-neutral-6; + font-size: $font-size-md; + } +} + +.error { + text-align: center; + padding: $spacing-2xl; + + p { + color: $color-neutral-6; + font-size: $font-size-md; + } +} + +.empty { + text-align: center; + padding: $spacing-2xl; + + p { + color: $color-neutral-6; + font-size: $font-size-md; + margin-bottom: $spacing-sm; + + &:last-child { + margin-bottom: 0; + font-size: $font-size-sm; + color: $color-neutral-6; + } + } +} + +.classList { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.classCard { + background-color: white; + border-radius: $radius-lg; + padding: $spacing-lg; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid $color-neutral-8; + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08); + + &:hover { + background-color: $color-skyblue; + } +} + +.classHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: $spacing-md; + margin-bottom: $spacing-md; + border-bottom: 0.5px solid $color-neutral-7; +} + +.classTitle { + display: flex; + align-items: center; + gap: $spacing-sm; +} + +.className { + font-size: $font-size-lg; + font-weight: $font-weight-medium; + color: $color-neutral-3; +} + +.teacherName { + font-size: $font-size-md; + color: $color-neutral-6; +} + +.arrowIcon { + color: $color-blue; + flex-shrink: 0; +} + +.classDetails { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.detailItem { + display: flex; + align-items: center; + gap: $spacing-sm; + color: $color-neutral-6; + font-size: $font-size-sm; + + svg { + color: $color-neutral-6; + flex-shrink: 0; + } + + span { + line-height: 1.4; + } +} diff --git a/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx b/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx index 2a665083..7dd3c654 100644 --- a/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx +++ b/frontend/app/student/class/_components/ClassListSection/ClassListSection.tsx @@ -1,5 +1,110 @@ -import React from "react"; +"use client"; + +import React, { useEffect, useState } from "react"; +import styles from "./ClassListSection.module.scss"; +import { fetchMyClassList } from "@/api/student-classes/fetchMyClassList"; +import { FetchMyClassListResult } from "@/types/classes/fetchMyClassListTypes"; +import { Calendar, Clock, ChevronRight } from "lucide-react"; +import { ROUTES } from "@/constants/routes"; +import router from "next/router"; export default function ClassListSection() { - return
ClassListSection
; + const [classList, setClassList] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadClassList = async () => { + try { + setIsLoading(true); + setError(null); + + const response = await fetchMyClassList(); + + if (response.isSuccess) { + setClassList(response.result || []); + } else { + setError( + response.message || "클래스 목록을 불러오는데 실패했습니다." + ); + } + } catch (error) { + console.error("클래스 목록 로드 오류:", error); + setError("클래스 목록을 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + loadClassList(); + }, []); + + const handleClassClick = (classId: string) => { + router.push(ROUTES.studentClassDetail(classId)); + }; + + if (isLoading) { + return ( +
+
+

클래스 목록을 불러오는 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+
+
+ ); + } + + if (classList.length === 0) { + return ( +
+
+

등록된 클래스가 없습니다.

+

QR 코드를 스캔하거나 입장코드를 입력하여 클래스에 참여해보세요.

+
+
+ ); + } + + return ( +
+
+ {classList.map((classItem) => ( +
handleClassClick(classItem.classId)} + > +
+
+ {classItem.className} + 박재성 +
+ +
+ +
+
+ + 월 (10:15~11:45)/수 (12:00~13:15) +
+ +
+ + 2024.03.04 ~ 2025.06.13 +
+
+
+ ))} +
+
+ ); } From 395b0a36c84f2127f20af2423601dbf45da0d3cc Mon Sep 17 00:00:00 2001 From: sunninz Date: Tue, 5 Aug 2025 17:42:57 +0900 Subject: [PATCH 12/45] =?UTF-8?q?:package:=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20CI=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CI-backend.yml | 65 +++++++++++++++++++ .../user/controller/UserController.java | 2 +- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/CI-backend.yml diff --git a/.github/workflows/CI-backend.yml b/.github/workflows/CI-backend.yml new file mode 100644 index 00000000..ec29370a --- /dev/null +++ b/.github/workflows/CI-backend.yml @@ -0,0 +1,65 @@ +name: ClassLog CI - Backend +on: + pull_request: + paths: + - 'backend/**' + branches: ["dev"] + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + backend-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name : Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name : Set up redis + uses: supercharge/redis-github-action@1.7.0 + with: + redis-version: 7 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + working-directory: ./backend + env: + DB_USERNAME: ${{ secrets.DB_HOST }} + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + DB_NAME: ${{ secrets.DB_NAME }} + DB_HOST: ${{ secrets.DB_HOST }} + DB_PORT: ${{ secrets.DB_PORT }} + REDIS_HOST: ${{ secrets.REDIS_HOST }} + REDIS_PORT: ${{ secrets.REDIS_PORT }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + MAIL_HOST: ${{ secrets.MAIL_HOST }} + MAIL_PORT: ${{ secrets.MAIL_PORT }} + MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} + MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} + AWS_REGION: ${{ secrets.AWS_REGION }} + AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} + AI_SERVER_URL: ${{ secrets.AI_SERVER_URL }} + AWS_S3_BUCKET_NAME: ${{ secrets.AWS_S3_BUCKET_NAME }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + KAKAO_API_KEY: ${{ secrets.KAKAO_API_KEY }} + KAKAO_REDIRECT_URI: ${{ secrets.KAKAO_REDIRECT_URI }} + run: ./gradlew build + + - name: ✅Passed + if: success() + run: echo "Backend Tests passed" + + - name: ❌Failed + if: failure() + run: echo "Backend Tests failed" \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/domain/user/controller/UserController.java b/backend/src/main/java/org/example/backend/domain/user/controller/UserController.java index 02f999c7..2855ad1a 100644 --- a/backend/src/main/java/org/example/backend/domain/user/controller/UserController.java +++ b/backend/src/main/java/org/example/backend/domain/user/controller/UserController.java @@ -33,7 +33,7 @@ public ApiResponse register(@Valid @RequestBody RegisterRequestDTO regis return ApiResponse.onSuccess("Register successfully"); } - // 개인정보조회 + // 개인 정보 조회 @GetMapping("/me") public ApiResponse profile(){ UserProfileResponseDTO response = userService.getProfile(); From c1c41d0dcc11a41ed6741c8d0b5fe4fbc4762e52 Mon Sep 17 00:00:00 2001 From: sunninz Date: Tue, 5 Aug 2025 17:48:04 +0900 Subject: [PATCH 13/45] =?UTF-8?q?:package:=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20CI=20gradlew=20=EB=94=94=EB=A0=89=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CI-backend.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/CI-backend.yml b/.github/workflows/CI-backend.yml index ec29370a..2a99e0a5 100644 --- a/.github/workflows/CI-backend.yml +++ b/.github/workflows/CI-backend.yml @@ -29,6 +29,7 @@ jobs: redis-version: 7 - name: Grant execute permission for gradlew + working-directory: ./backend run: chmod +x gradlew - name: Build with Gradle From becb6b147986b3fd6140da9b1f56bbcc23f88af4 Mon Sep 17 00:00:00 2001 From: sunninz Date: Tue, 5 Aug 2025 18:03:18 +0900 Subject: [PATCH 14/45] =?UTF-8?q?:package:=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=20CI=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CI-frontend.yml | 41 +++++++++++++++++++++++++++++ frontend/api/classes/createClass.ts | 1 + 2 files changed, 42 insertions(+) create mode 100644 .github/workflows/CI-frontend.yml diff --git a/.github/workflows/CI-frontend.yml b/.github/workflows/CI-frontend.yml new file mode 100644 index 00000000..0a5102b4 --- /dev/null +++ b/.github/workflows/CI-frontend.yml @@ -0,0 +1,41 @@ +name: ClassLog CI - Frontend +on: + pull_request: + paths: + - 'frontend/**' + branches: ["dev"] + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + frontend-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install dependencies + working-directory: ./frontend + run: npm install + + - name: Build with npm + working-directory: ./frontend + env: + NEXT_PUBLIC_API_BASE_URL : ${{ secrets.NEXT_PUBLIC_API_BASE_URL }} + run: npm run build + + - name: ✅Passed + if: success() + run: echo "Frontend Tests passed" + + - name: ❌Failed + if: failure() + run: echo "Frontend Tests failed" diff --git a/frontend/api/classes/createClass.ts b/frontend/api/classes/createClass.ts index ad78c86b..a52005ce 100644 --- a/frontend/api/classes/createClass.ts +++ b/frontend/api/classes/createClass.ts @@ -32,4 +32,5 @@ export async function createClass({ } throw error; } + // CI 테스트 주석 } From 98c339f6ee3aa57f5fa573681c4dd84f64891778 Mon Sep 17 00:00:00 2001 From: Haemin Kim Date: Tue, 5 Aug 2025 21:09:58 +0900 Subject: [PATCH 15/45] =?UTF-8?q?:sparkles:=20(#271)=20=ED=80=B4=EC=A6=88?= =?UTF-8?q?=20=EC=A0=95=EB=8B=B5=20=EC=A1=B0=ED=9A=8C=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/QuizAnswerController.java | 21 +++++- .../QuizResultStudentResponseDTO.java | 4 + .../service/QuizResultStudentService.java | 9 +++ .../service/QuizResultStudentServiceImpl.java | 73 +++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/org/example/backend/domain/quizAnswer/dto/response/QuizResultStudentResponseDTO.java create mode 100644 backend/src/main/java/org/example/backend/domain/quizAnswer/service/QuizResultStudentService.java create mode 100644 backend/src/main/java/org/example/backend/domain/quizAnswer/service/QuizResultStudentServiceImpl.java diff --git a/backend/src/main/java/org/example/backend/domain/quizAnswer/controller/QuizAnswerController.java b/backend/src/main/java/org/example/backend/domain/quizAnswer/controller/QuizAnswerController.java index 29672400..c9d0746f 100644 --- a/backend/src/main/java/org/example/backend/domain/quizAnswer/controller/QuizAnswerController.java +++ b/backend/src/main/java/org/example/backend/domain/quizAnswer/controller/QuizAnswerController.java @@ -4,11 +4,13 @@ import lombok.RequiredArgsConstructor; import org.example.backend.domain.quizAnswer.dto.request.QuizSubmitRequestDTO; import org.example.backend.domain.quizAnswer.dto.response.QuizInfoResponseDTO; +import org.example.backend.domain.quizAnswer.dto.response.QuizResultStudentResponseDTO; import org.example.backend.domain.quizAnswer.dto.response.QuizSubmitListResponseDTO; import org.example.backend.domain.quiz.exception.QuizException; import org.example.backend.domain.quizAnswer.dto.response.QuizSubmitResponseDTO; import org.example.backend.domain.quizAnswer.service.QuizAnswerService; import org.example.backend.domain.quizAnswer.service.QuizInfoService; +import org.example.backend.domain.quizAnswer.service.QuizResultStudentService; import org.example.backend.domain.quizAnswer.service.QuizSubmitService; import org.example.backend.global.ApiResponse; import org.example.backend.global.code.base.FailureCode; @@ -25,6 +27,7 @@ public class QuizAnswerController { private final QuizAnswerService quizAnswerService; private final QuizInfoService quizInfoService; private final QuizSubmitService quizSubmitService; + private final QuizResultStudentService quizResultStudentService; // 퀴즈 제출 학생 목록 조회 @@ -73,7 +76,23 @@ public ResponseEntity> submitQuiz( .status(e.getErrorCode().getReasonHttpStatus().getHttpStatus()) .body(ApiResponse.onFailure(e.getErrorCode())); } catch (Exception e) { - e.printStackTrace(); // 콘솔에 전체 스택트레이스 출력 + return ResponseEntity + .status(FailureCode._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus()) + .body(ApiResponse.onFailure(FailureCode._INTERNAL_SERVER_ERROR)); + } + } + + // 학생 별 퀴즈 선택 결과 + @GetMapping("/{lectureId}/result/student") + public ResponseEntity> getQuizResult(@PathVariable("lectureId") UUID lectureId) { + try{ + QuizResultStudentResponseDTO result = quizResultStudentService.getQuizResult(lectureId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } catch (QuizException e) { + return ResponseEntity + .status(e.getErrorCode().getReasonHttpStatus().getHttpStatus()) + .body(ApiResponse.onFailure(e.getErrorCode())); + } catch (Exception e) { return ResponseEntity .status(FailureCode._INTERNAL_SERVER_ERROR.getReasonHttpStatus().getHttpStatus()) .body(ApiResponse.onFailure(FailureCode._INTERNAL_SERVER_ERROR)); diff --git a/backend/src/main/java/org/example/backend/domain/quizAnswer/dto/response/QuizResultStudentResponseDTO.java b/backend/src/main/java/org/example/backend/domain/quizAnswer/dto/response/QuizResultStudentResponseDTO.java new file mode 100644 index 00000000..55cf17f4 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizAnswer/dto/response/QuizResultStudentResponseDTO.java @@ -0,0 +1,4 @@ +package org.example.backend.domain.quizAnswer.dto.response; + +public class QuizResultStudentResponseDTO { +} diff --git a/backend/src/main/java/org/example/backend/domain/quizAnswer/service/QuizResultStudentService.java b/backend/src/main/java/org/example/backend/domain/quizAnswer/service/QuizResultStudentService.java new file mode 100644 index 00000000..612966c4 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizAnswer/service/QuizResultStudentService.java @@ -0,0 +1,9 @@ +package org.example.backend.domain.quizAnswer.service; + +import org.example.backend.domain.quizAnswer.dto.response.QuizResultStudentResponseDTO; + +import java.util.UUID; + +public interface QuizResultStudentService { + QuizResultStudentResponseDTO getQuizResult(UUID lectureId); +} diff --git a/backend/src/main/java/org/example/backend/domain/quizAnswer/service/QuizResultStudentServiceImpl.java b/backend/src/main/java/org/example/backend/domain/quizAnswer/service/QuizResultStudentServiceImpl.java new file mode 100644 index 00000000..040f06bc --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizAnswer/service/QuizResultStudentServiceImpl.java @@ -0,0 +1,73 @@ +package org.example.backend.domain.quizAnswer.service; + +import lombok.RequiredArgsConstructor; +import org.example.backend.domain.lecture.entity.Lecture; +import org.example.backend.domain.lecture.repository.LectureRepository; +import org.example.backend.domain.option.dto.response.OptionResponseDTO; +import org.example.backend.domain.quiz.dto.response.QuizListResponseDTO; +import org.example.backend.domain.quiz.entity.Quiz; +import org.example.backend.domain.quiz.entity.QuizType; +import org.example.backend.domain.quiz.exception.QuizErrorCode; +import org.example.backend.domain.quiz.exception.QuizException; +import org.example.backend.domain.quiz.repository.QuizRepository; +import org.example.backend.domain.quizAnswer.dto.response.QuizResultStudentResponseDTO; +import org.example.backend.domain.quizAnswer.repository.QuizAnswerRepository; +import org.example.backend.domain.user.entity.Role; +import org.example.backend.global.security.auth.CustomSecurityUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class QuizResultStudentServiceImpl implements QuizResultStudentService { + + private final CustomSecurityUtil customSecurityUtil; + private final QuizRepository quizRepository; + private final QuizAnswerRepository quizAnswerRepository; + private final LectureRepository lectureRepository; + + // 학생 별 퀴즈 결과 + @Override + @Transactional(readOnly = true) + public QuizResultStudentResponseDTO getQuizResult(UUID lectureId) { + Lecture lecture = lectureRepository.findById(lectureId) + .orElseThrow(() -> new QuizException(QuizErrorCode.LECTURE_NOT_FOUND)); + + List quizList = quizRepository.findByLectureId(lectureId); + + if (role == Role.STUDENT) { + throw new QuizException(QuizErrorCode.STUDENT_NOT_CREATE_QUIZ); + } + + if (quizList.isEmpty()) { + throw new QuizException(QuizErrorCode.QUIZ_NOT_GENERATED_YET); + } + + 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(), + quiz.getType().name(), + options + ); + }).toList(); + + return new QuizListResponseDTO(lectureId, quizDTOs); } +} From 7beccd206f771cc9f6f975250af2076b56b437bb Mon Sep 17 00:00:00 2001 From: Haemin Kim Date: Mon, 11 Aug 2025 10:42:44 +0900 Subject: [PATCH 16/45] =?UTF-8?q?:sparkles:=20(#271)=20=ED=95=99=EC=83=9D?= =?UTF-8?q?=EB=B3=84=20=ED=80=B4=EC=A6=88=20=EC=84=A0=ED=83=9D=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/quiz/exception/QuizErrorCode.java | 1 + .../converter/QuizAnswerConverter.java | 8 ++- .../converter/QuizResultStudentConverter.java | 68 +++++++++++++++++++ .../QuizResultStudentResponseDTO.java | 41 +++++++++++ .../repository/QuizAnswerRepository.java | 2 + .../service/QuizResultStudentServiceImpl.java | 63 +++++++++-------- 6 files changed, 154 insertions(+), 29 deletions(-) create mode 100644 backend/src/main/java/org/example/backend/domain/quizAnswer/converter/QuizResultStudentConverter.java diff --git a/backend/src/main/java/org/example/backend/domain/quiz/exception/QuizErrorCode.java b/backend/src/main/java/org/example/backend/domain/quiz/exception/QuizErrorCode.java index 292f4f61..fbb9e5e3 100644 --- a/backend/src/main/java/org/example/backend/domain/quiz/exception/QuizErrorCode.java +++ b/backend/src/main/java/org/example/backend/domain/quiz/exception/QuizErrorCode.java @@ -18,6 +18,7 @@ public enum QuizErrorCode implements BaseErrorCode { DUPLICATE_SUBMISSION(HttpStatus.BAD_REQUEST,"QUIZ400_3", "이미 해당 퀴즈에 대한 답안을 제출하였습니다."), AUDIO_NOT_FOUND(HttpStatus.NOT_FOUND, "QUIZ404_2", "녹음본 기반 퀴즈는 서비스 내에서 강의 시작 후 녹음이 완료된 경우에만 생성할 수 있습니다."), QUIZ_NOT_GENERATED_YET(HttpStatus.NOT_FOUND, "QUIZ404_3", "퀴즈가 아직 업로드되지 않았어요!"), + QUIZ_ANSWER_NOT_FOUND(HttpStatus.NOT_FOUND, "QUIZ404_4", "퀴즈를 제출하지 않았습니다."), AI_CALL_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI_500", "AI 호출 중 오류가 발생했습니다."); private final HttpStatus httpStatus; diff --git a/backend/src/main/java/org/example/backend/domain/quizAnswer/converter/QuizAnswerConverter.java b/backend/src/main/java/org/example/backend/domain/quizAnswer/converter/QuizAnswerConverter.java index 20667e9f..6070edcf 100644 --- a/backend/src/main/java/org/example/backend/domain/quizAnswer/converter/QuizAnswerConverter.java +++ b/backend/src/main/java/org/example/backend/domain/quizAnswer/converter/QuizAnswerConverter.java @@ -1,12 +1,18 @@ package org.example.backend.domain.quizAnswer.converter; +import org.example.backend.domain.option.entity.Option; import org.example.backend.domain.quiz.entity.Quiz; +import org.example.backend.domain.quizAnswer.dto.response.QuizResultStudentResponseDTO; import org.example.backend.domain.quizAnswer.dto.response.QuizSubmitResponseDTO; import org.example.backend.domain.quizAnswer.entity.QuizAnswer; import org.example.backend.domain.user.repository.UserRepository; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; +import java.util.stream.Collectors; @Component public class QuizAnswerConverter { @@ -26,6 +32,4 @@ public static QuizSubmitResponseDTO toSubmitResponse(UUID userId, int savedCount .savedCount(savedCount) .build(); } - - } \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/domain/quizAnswer/converter/QuizResultStudentConverter.java b/backend/src/main/java/org/example/backend/domain/quizAnswer/converter/QuizResultStudentConverter.java new file mode 100644 index 00000000..6e7ad294 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/quizAnswer/converter/QuizResultStudentConverter.java @@ -0,0 +1,68 @@ +package org.example.backend.domain.quizAnswer.converter; + +import org.example.backend.domain.option.entity.Option; +import org.example.backend.domain.quiz.entity.Quiz; +import org.example.backend.domain.quizAnswer.dto.response.QuizResultStudentResponseDTO; +import org.example.backend.domain.quizAnswer.entity.QuizAnswer; +import org.springframework.stereotype.Component; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Component +public class QuizResultStudentConverter { + public static QuizResultStudentResponseDTO toResultStudentResponse( + UUID lectureId, + List quizzes + ) { + return QuizResultStudentResponseDTO.builder() + .lectureId(lectureId) + .quizzes(quizzes) + .build(); + } + + public static QuizResultStudentResponseDTO.QuizDTO toQuizDTO( + Quiz quiz, + List