diff --git a/frontend/src/assets/icons/close.svg b/frontend/src/assets/icons/close.svg
new file mode 100644
index 0000000..1aedaf8
--- /dev/null
+++ b/frontend/src/assets/icons/close.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/components/ChatRoom/ChatRoom.jsx b/frontend/src/components/ChatRoom/ChatRoom.jsx
index 01f8ee3..7aa7d23 100644
--- a/frontend/src/components/ChatRoom/ChatRoom.jsx
+++ b/frontend/src/components/ChatRoom/ChatRoom.jsx
@@ -1,11 +1,23 @@
import styles from './ChatRoom.module.css';
import SendIcon from '/src/assets/icons/send.svg?react';
-import { cloneElement, useEffect, useRef } from 'react';
+import { cloneElement, Suspense, useEffect, useRef, useState } from 'react';
import { ChatEntry, useChat, useMaybeLayoutContext } from '@livekit/components-react';
+import { useParams } from 'react-router-dom';
+import useChatRoom from '../../hooks/chat/useChatRoom';
+import { createPortal } from 'react-dom';
+import { QuizModal } from '../QuizModal';
+import { QuizSet } from '../QuizSet';
-export default function ChatRoom({ ...props }) {
+export default function ChatRoom({ isTeacher, ...props }) {
+ const { roomId } = useParams();
+ const wsChat = useChatRoom(roomId);
const chatInputRef = useRef(null);
const ulRef = useRef(null);
+ const [isQuizModalOpen, setIsQuizModalOpen] = useState(false);
+
+ const openQuizModal = () => {
+ setIsQuizModalOpen(true);
+ };
const { send, chatMessages, isSending } = useChat();
@@ -54,59 +66,94 @@ export default function ChatRoom({ ...props }) {
}, [chatMessages, layoutContext, layoutContext.widget]);
return (
-
-
채팅
-
-
+
- {props.children
- ? chatMessages.map((msg, idx) =>
- cloneElement(props.children, {
- entry: msg,
- key: msg.id ?? idx,
- })
- )
- : chatMessages.map((msg, idx, allMsg) => {
- const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from;
- const hideTimestamp = idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000;
+
+ 채팅
+ {isTeacher && (
+
+ )}
+
- return (
-
- );
- })}
-
-
-
+ {props.children
+ ? chatMessages.map((msg, idx) =>
+ cloneElement(props.children, {
+ entry: msg,
+ key: msg.id ?? idx,
+ })
+ )
+ : chatMessages.map((msg, idx, allMsg) => {
+ const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from;
+ const hideTimestamp = idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000;
+
+ return (
+
+ );
+ })}
+
+
+
+ {wsChat.quizSetId && (
+
+
+ >}>
+ wsChat.setQuizSetId(null)}
+ />
+
+
+ )}
+
+ {isQuizModalOpen &&
+ createPortal(
+ setIsQuizModalOpen(false)}
+ />,
+ document.body
+ )}
+ >
);
}
diff --git a/frontend/src/components/ChatRoom/ChatRoom.module.css b/frontend/src/components/ChatRoom/ChatRoom.module.css
index cafedf0..62106c5 100644
--- a/frontend/src/components/ChatRoom/ChatRoom.module.css
+++ b/frontend/src/components/ChatRoom/ChatRoom.module.css
@@ -8,9 +8,39 @@
overflow-y: hidden;
}
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px;
+
+ & > .quizButton {
+ border-radius: 8px;
+ border: none;
+ padding: 8px 12px;
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1.4;
+ font-weight: 700;
+ border: 1px solid var(--border-color-tertiary);
+ background-color: #1e1e1e;
+ transition:
+ background-color 0.2s,
+ scale 0.1s;
+
+ &:hover,
+ &:active {
+ background-color: #2b2b2b;
+ }
+
+ &:active {
+ scale: 0.95;
+ }
+ }
+}
+
.title {
margin: 0;
- padding: 16px;
}
.messageList {
@@ -133,3 +163,7 @@
.button {
stroke: white;
}
+
+.none {
+ display: none !important;
+}
diff --git a/frontend/src/components/ClassCard/ClassCard.jsx b/frontend/src/components/ClassCard/ClassCard.jsx
index 20ed9da..dd1f257 100644
--- a/frontend/src/components/ClassCard/ClassCard.jsx
+++ b/frontend/src/components/ClassCard/ClassCard.jsx
@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import styles from './ClassCard.module.css';
import CompassIcon from '/src/assets/icons/compass.svg?react';
+import { STATIC_URL } from '../../constants';
export default function ClassCard({ img, path, children }) {
return (
@@ -10,7 +11,7 @@ export default function ClassCard({ img, path, children }) {
>
{img ? (
diff --git a/frontend/src/components/LiveRoom/LiveRoom.jsx b/frontend/src/components/LiveRoom/LiveRoom.jsx
index 38d94c6..614b1a1 100644
--- a/frontend/src/components/LiveRoom/LiveRoom.jsx
+++ b/frontend/src/components/LiveRoom/LiveRoom.jsx
@@ -120,7 +120,7 @@ export default function LiveRoom() {
controls={{ chat: false, leave: true, screenShare: role === '강사' }}
/>
-
+
diff --git a/frontend/src/components/Quiz/Quiz.jsx b/frontend/src/components/Quiz/Quiz.jsx
new file mode 100644
index 0000000..ebc8cae
--- /dev/null
+++ b/frontend/src/components/Quiz/Quiz.jsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+import { STATIC_URL } from '../../constants';
+import styles from './Quiz.module.css';
+
+export default function Quiz({ question, step, image, choices = [], setAnswers }) {
+ const [answer, setAnswer] = useState(null);
+ const isChoice = choices.length > 0;
+
+ return (
+
+
+ {question}
+ {image && (
+
+ )}
+
+
+ {isChoice ? (
+ choices.map(({ id, num, content }) => (
+
{
+ setAnswer(num);
+ setAnswers(num);
+ }}
+ className={`${styles.choice} ${answer === num ? styles.active : ''}`}
+ >
+ {num}
+ {content}
+
+ ))
+ ) : (
+
+ setAnswers((prev) => prev.map((value, index) => (index === step ? e.target.value : value)))
+ }
+ className={styles.input}
+ />
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/Quiz/Quiz.module.css b/frontend/src/components/Quiz/Quiz.module.css
new file mode 100644
index 0000000..f8c9234
--- /dev/null
+++ b/frontend/src/components/Quiz/Quiz.module.css
@@ -0,0 +1,78 @@
+@keyframes show {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.quiz {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 100%;
+ height: 100%;
+ padding: 0 16px;
+ box-sizing: border-box;
+}
+
+.header {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ animation: show 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+
+ & > h1 {
+ font-size: 24px;
+ font-style: normal;
+ font-weight: 700;
+ line-height: 1.2;
+ }
+
+ & > img {
+ width: 100%;
+ height: auto;
+ max-height: 200px;
+ object-fit: contain;
+ }
+}
+
+.choiceWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ animation: show 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.5s forwards;
+ opacity: 0;
+}
+
+.choice {
+ display: flex;
+ gap: 20px;
+ padding: 12px 20px;
+ border-radius: 8px;
+ border: 1px solid var(--border-color-tertiary);
+ font-size: 16px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 1.4;
+ box-sizing: border-box;
+ cursor: pointer;
+
+ & > .number {
+ font-weight: 700;
+ }
+
+ &.active {
+ border-color: var(--primary-color);
+ color: var(--primary-color);
+ }
+}
+
+.input {
+ padding: 12px 16px;
+ border-radius: 8px;
+ border: 1px solid var(--border-color-tertiary);
+}
diff --git a/frontend/src/components/Quiz/index.js b/frontend/src/components/Quiz/index.js
new file mode 100644
index 0000000..c239c50
--- /dev/null
+++ b/frontend/src/components/Quiz/index.js
@@ -0,0 +1 @@
+export { default as Quiz } from './Quiz';
diff --git a/frontend/src/components/QuizModal/QuizModal.jsx b/frontend/src/components/QuizModal/QuizModal.jsx
new file mode 100644
index 0000000..6b9e013
--- /dev/null
+++ b/frontend/src/components/QuizModal/QuizModal.jsx
@@ -0,0 +1,45 @@
+import styles from './QuizModal.module.css';
+import { useRef } from 'react';
+import CloseIcon from '/src/assets/icons/close.svg?react';
+
+export default function QuizModal({ startQuiz, quizSets, closeModal }) {
+ const wrapperRef = useRef(null);
+
+ const handleClick = (e) => {
+ if (e.target === wrapperRef.current) {
+ closeModal();
+ }
+ };
+
+ return (
+
+
+
+
+ {quizSets.map((quizSet) => (
+ - {
+ console.log(quizSet.quizSetId);
+ startQuiz(quizSet.quizSetId);
+ closeModal();
+ }}
+ className={styles.quiz}
+ >
+ {quizSet.title}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/QuizModal/QuizModal.module.css b/frontend/src/components/QuizModal/QuizModal.module.css
new file mode 100644
index 0000000..83db8cd
--- /dev/null
+++ b/frontend/src/components/QuizModal/QuizModal.module.css
@@ -0,0 +1,94 @@
+@keyframes show {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(20px);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+.wrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 100px;
+ background-color: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(12px);
+ z-index: 1000;
+ animation: show 0.25s;
+}
+
+.modal {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+ max-width: 320px;
+ padding: 16px;
+ color: var(--background-secondary);
+ background-color: #111;
+ border-radius: 12px;
+ box-shadow: var(--shadow);
+ animation: slideUp 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+}
+
+.title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 24px;
+ line-height: 1.2;
+ font-weight: 700;
+ margin: 0;
+ stroke: var(--background);
+
+ & > button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ width: 32px;
+ height: 32px;
+ }
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.quiz {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 16px;
+ border-radius: 8px;
+ border: none;
+ background-color: var(--text-color);
+ color: var(--on-primary);
+ font-size: 16px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.25s;
+}
diff --git a/frontend/src/components/QuizModal/index.js b/frontend/src/components/QuizModal/index.js
new file mode 100644
index 0000000..456a021
--- /dev/null
+++ b/frontend/src/components/QuizModal/index.js
@@ -0,0 +1 @@
+export { default as QuizModal } from './QuizModal';
diff --git a/frontend/src/components/QuizSet/QuizSet.jsx b/frontend/src/components/QuizSet/QuizSet.jsx
new file mode 100644
index 0000000..d228ac0
--- /dev/null
+++ b/frontend/src/components/QuizSet/QuizSet.jsx
@@ -0,0 +1,91 @@
+import styles from './QuizSet.module.css';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useQuizsetDetail } from '../../hooks/api/useQuizsetDetail';
+import Quiz from '../Quiz/Quiz';
+import { useParams } from 'react-router-dom';
+import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator';
+import instance from '../../utils/axios/instance';
+import { API_URL } from '../../constants';
+
+export default function QuizSet({ quizSetId, finish }) {
+ const { roomId } = useParams();
+ const [step, setStep] = useState(null);
+ const { data } = useQuizsetDetail(quizSetId);
+ const quizSetData = data?.data;
+ const quizList = quizSetData.quizzes;
+ const answers = useRef(Array(quizList.length).fill(null));
+ const interval = useRef(null);
+ const submit = useCallback(
+ (data) => {
+ instance.post(`${API_URL}/report/submit/${roomId}/quizset/${quizSetId}`, data).catch(() => {});
+ },
+ [quizSetId, roomId]
+ );
+ const QuizComponents = [
+ ...quizList.map((quiz, index) => (
+ {
+ answers.current = answers.current.map((v, i) => (i === index ? value : v));
+ }}
+ {...quiz}
+ />
+ )),
+
+ 퀴즈 종료
+
,
+ ];
+
+ useEffect(() => {
+ if (!quizList) {
+ return;
+ }
+
+ interval.current = setInterval(() => {
+ setStep((prev) => {
+ if (prev === null) {
+ return 0;
+ }
+
+ if (prev + 1 === quizList.length) {
+ submit(answers.current);
+ return Infinity;
+ }
+
+ return prev + 1;
+ });
+ }, 5000);
+
+ return () => {
+ clearInterval(interval.current);
+ interval.current = null;
+ };
+ }, [quizList, submit]);
+
+ useEffect(() => {
+ if (step === Infinity) {
+ finish();
+ }
+ }, [finish, step]);
+
+ return (
+ <>
+ {step === null ? (
+
+ 퀴즈를 시작합니다
+
+
+ ) : (
+ <>
+ {QuizComponents[step]}
+
+ >
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/QuizSet/QuizSet.module.css b/frontend/src/components/QuizSet/QuizSet.module.css
new file mode 100644
index 0000000..7057497
--- /dev/null
+++ b/frontend/src/components/QuizSet/QuizSet.module.css
@@ -0,0 +1,44 @@
+@keyframes show {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.message {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 32px;
+ width: 100%;
+ height: 100%;
+ font-size: 24px;
+ font-style: normal;
+ font-weight: 700;
+ line-height: 1.2;
+ animation: show 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
+}
+
+@keyframes progress {
+ from {
+ transform: scaleX(0);
+ }
+ to {
+ transform: scaleX(1);
+ }
+}
+
+.progressBar {
+ width: 100%;
+ height: 8px;
+ background-color: var(--primary-color);
+ overflow: hidden;
+ margin-top: 16px;
+ transform-origin: left center;
+ animation: progress 5s linear infinite;
+}
diff --git a/frontend/src/components/QuizSet/index.js b/frontend/src/components/QuizSet/index.js
new file mode 100644
index 0000000..cfe9891
--- /dev/null
+++ b/frontend/src/components/QuizSet/index.js
@@ -0,0 +1 @@
+export { default as QuizSet } from './QuizSet';
diff --git a/frontend/src/components/QuizsetDetail/QuizsetDetail.jsx b/frontend/src/components/QuizsetDetail/QuizsetDetail.jsx
index 9177e69..cae045f 100644
--- a/frontend/src/components/QuizsetDetail/QuizsetDetail.jsx
+++ b/frontend/src/components/QuizsetDetail/QuizsetDetail.jsx
@@ -1,6 +1,7 @@
import BackIcon from '/src/assets/icons/back.svg?react';
import { Link } from 'react-router-dom';
import styles from './QuizsetDetail.module.css';
+import { STATIC_URL } from '../../constants';
export default function QuizsetDetail({ topic, title, quizzes = [], onDelete, onEdit }) {
return (
@@ -23,7 +24,7 @@ export default function QuizsetDetail({ topic, title, quizzes = [], onDelete, on
질문 : {quiz.question}
{quiz.image && (
diff --git a/frontend/src/constants.js b/frontend/src/constants.js
index 8473765..a0aa684 100644
--- a/frontend/src/constants.js
+++ b/frontend/src/constants.js
@@ -1,3 +1,4 @@
export const API_URL = import.meta.env.VITE_API_URL;
export const ROOM_URL = import.meta.env.VITE_ROOM_URL;
export const CHAT_URL = import.meta.env.VITE_CHAT_URL;
+export const STATIC_URL = import.meta.env.VITE_STATIC_URL;
diff --git a/frontend/src/hooks/api/useQuizsets.js b/frontend/src/hooks/api/useQuizsets.js
index 2583339..c25a4ba 100644
--- a/frontend/src/hooks/api/useQuizsets.js
+++ b/frontend/src/hooks/api/useQuizsets.js
@@ -1,9 +1,9 @@
-import { useQuery } from '@tanstack/react-query';
+import { useSuspenseQuery } from '@tanstack/react-query';
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQuizsets() {
- return useQuery({
+ return useSuspenseQuery({
queryKey: ['quizsetList'],
queryFn: () => instance.get(`${API_URL}/quiz`),
});
diff --git a/frontend/src/hooks/chat/useChatRoom.js b/frontend/src/hooks/chat/useChatRoom.js
index 925b2e0..a599114 100644
--- a/frontend/src/hooks/chat/useChatRoom.js
+++ b/frontend/src/hooks/chat/useChatRoom.js
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { chatClient } from '../../utils/chat/chatClient';
import useBoundStore from '../../store';
+import { useQuizsets } from '../api/useQuizsets';
const USER_ID = crypto.getRandomValues(new Uint32Array(1))[0];
@@ -10,6 +11,19 @@ export default function useChatRoom(roomId) {
const userName = useBoundStore((state) => state.userName) ?? '익명';
const inputRef = useRef(null);
const chatListRef = useRef(null);
+ const { data: quizSetData } = useQuizsets();
+ const quizSets = quizSetData?.data ?? [];
+ const [quizSetId, setQuizSetId] = useState(null);
+
+ const startQuiz = (quizSetId) => {
+ chatClient.publish({
+ destination: `/pub/chat.quiz.${roomId}`,
+ body: JSON.stringify({
+ userId: USER_ID,
+ quizSetId,
+ }),
+ });
+ };
const handleSubmit = (e) => {
e.preventDefault();
@@ -34,8 +48,12 @@ export default function useChatRoom(roomId) {
client.onConnect = () => {
client.subscribe(`/exchange/chat.exchange/*.room.${roomId}`, (response) => {
const data = JSON.parse(response.body);
- const { content: message, name } = data;
+ const { content: message, name, quizSetId } = data;
+ if (quizSetId !== undefined) {
+ setQuizSetId(quizSetId);
+ return;
+ }
setMessages((prev) => [...prev, { id: prev.length, text: message, isMine: USER_ID === data.userId, name }]);
});
};
@@ -47,7 +65,9 @@ export default function useChatRoom(roomId) {
}, [client, roomId]);
useEffect(() => {
- chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
+ if (chatListRef.current) {
+ chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
+ }
}, [messages]);
return {
@@ -55,5 +75,9 @@ export default function useChatRoom(roomId) {
inputRef,
handleSubmit,
chatListRef,
+ startQuiz,
+ quizSets,
+ quizSetId,
+ setQuizSetId,
};
}
diff --git a/frontend/src/pages/LivePage/LivePage.jsx b/frontend/src/pages/LivePage/LivePage.jsx
index d209a8b..85e1a38 100644
--- a/frontend/src/pages/LivePage/LivePage.jsx
+++ b/frontend/src/pages/LivePage/LivePage.jsx
@@ -33,8 +33,14 @@ export default function LivePage() {
connect={true}
data-lk-theme="default"
onDisconnected={() => {
- instance.post(`${API_URL}/video/deleteroom/${roomId}`).catch(() => {});
- window.close();
+ setTimeout(() => {
+ instance
+ .post(`${API_URL}/video/deleteroom/${roomId}`)
+ .catch(() => {})
+ .finally(() => {
+ window.close();
+ });
+ }, 500);
}}
>