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 ( -
-

채팅

- - -
- ev.stopPropagation()} - onKeyDown={(ev) => ev.stopPropagation()} - onKeyUp={(ev) => ev.stopPropagation()} - /> - -
-
+ {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 ( + + ); + })} + +
+ ev.stopPropagation()} + onKeyDown={(ev) => ev.stopPropagation()} + onKeyUp={(ev) => ev.stopPropagation()} + /> + +
+ + {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); }} >