feat: 퀴즈 추가
This commit is contained in:
parent
03ec142ec3
commit
72850444ed
5
frontend/src/assets/icons/close.svg
Normal file
5
frontend/src/assets/icons/close.svg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g id="X">
|
||||||
|
<path id="Icon" d="M24 8.5L8 24.5M8 8.5L24 24.5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 234 B |
@ -1,11 +1,23 @@
|
|||||||
import styles from './ChatRoom.module.css';
|
import styles from './ChatRoom.module.css';
|
||||||
import SendIcon from '/src/assets/icons/send.svg?react';
|
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 { 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 chatInputRef = useRef(null);
|
||||||
const ulRef = useRef(null);
|
const ulRef = useRef(null);
|
||||||
|
const [isQuizModalOpen, setIsQuizModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const openQuizModal = () => {
|
||||||
|
setIsQuizModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const { send, chatMessages, isSending } = useChat();
|
const { send, chatMessages, isSending } = useChat();
|
||||||
|
|
||||||
@ -54,59 +66,94 @@ export default function ChatRoom({ ...props }) {
|
|||||||
}, [chatMessages, layoutContext, layoutContext.widget]);
|
}, [chatMessages, layoutContext, layoutContext.widget]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
{...props}
|
<div
|
||||||
className="lk-chat"
|
{...props}
|
||||||
>
|
className={`lk-chat ${wsChat.quizSetId ? styles.none : ''}`}
|
||||||
<h2 className={styles.title}>채팅</h2>
|
|
||||||
|
|
||||||
<ul
|
|
||||||
className="lk-list lk-chat-messages"
|
|
||||||
ref={ulRef}
|
|
||||||
>
|
>
|
||||||
{props.children
|
<header className={styles.header}>
|
||||||
? chatMessages.map((msg, idx) =>
|
<h2 className={styles.title}>채팅</h2>
|
||||||
cloneElement(props.children, {
|
{isTeacher && (
|
||||||
entry: msg,
|
<button
|
||||||
key: msg.id ?? idx,
|
className={styles.quizButton}
|
||||||
})
|
onClick={openQuizModal}
|
||||||
)
|
>
|
||||||
: chatMessages.map((msg, idx, allMsg) => {
|
퀴즈 시작
|
||||||
const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from;
|
</button>
|
||||||
const hideTimestamp = idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000;
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
return (
|
<ul
|
||||||
<ChatEntry
|
className="lk-list lk-chat-messages"
|
||||||
key={msg.id ?? idx}
|
ref={ulRef}
|
||||||
hideName={hideName}
|
|
||||||
hideTimestamp={hideName === false ? false : hideTimestamp}
|
|
||||||
entry={msg}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<form
|
|
||||||
className="lk-chat-form"
|
|
||||||
onSubmit={handleChatSubmit}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="lk-form-control lk-chat-form-input"
|
|
||||||
disabled={isSending}
|
|
||||||
ref={chatInputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="메시지"
|
|
||||||
onInput={(ev) => ev.stopPropagation()}
|
|
||||||
onKeyDown={(ev) => ev.stopPropagation()}
|
|
||||||
onKeyUp={(ev) => ev.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className={`lk-button lk-chat-form-button ${styles.button}`}
|
|
||||||
disabled={isSending}
|
|
||||||
>
|
>
|
||||||
<SendIcon />
|
{props.children
|
||||||
</button>
|
? chatMessages.map((msg, idx) =>
|
||||||
</form>
|
cloneElement(props.children, {
|
||||||
</div>
|
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 (
|
||||||
|
<ChatEntry
|
||||||
|
key={msg.id ?? idx}
|
||||||
|
hideName={hideName}
|
||||||
|
hideTimestamp={hideName === false ? false : hideTimestamp}
|
||||||
|
entry={msg}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<form
|
||||||
|
className="lk-chat-form"
|
||||||
|
onSubmit={handleChatSubmit}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="lk-form-control lk-chat-form-input"
|
||||||
|
disabled={isSending}
|
||||||
|
ref={chatInputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="메시지"
|
||||||
|
onInput={(ev) => ev.stopPropagation()}
|
||||||
|
onKeyDown={(ev) => ev.stopPropagation()}
|
||||||
|
onKeyUp={(ev) => ev.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`lk-button lk-chat-form-button ${styles.button}`}
|
||||||
|
disabled={isSending}
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{wsChat.quizSetId && (
|
||||||
|
<div className="lk-chat">
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h2 className={styles.title}>퀴즈</h2>
|
||||||
|
</header>
|
||||||
|
<Suspense fallback={<></>}>
|
||||||
|
<QuizSet
|
||||||
|
quizSetId={wsChat.quizSetId}
|
||||||
|
finish={() => wsChat.setQuizSetId(null)}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isQuizModalOpen &&
|
||||||
|
createPortal(
|
||||||
|
<QuizModal
|
||||||
|
startQuiz={wsChat.startQuiz}
|
||||||
|
quizSets={wsChat.quizSets}
|
||||||
|
closeModal={() => setIsQuizModalOpen(false)}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,9 +8,39 @@
|
|||||||
overflow-y: hidden;
|
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 {
|
.title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.messageList {
|
.messageList {
|
||||||
@ -133,3 +163,7 @@
|
|||||||
.button {
|
.button {
|
||||||
stroke: white;
|
stroke: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styles from './ClassCard.module.css';
|
import styles from './ClassCard.module.css';
|
||||||
import CompassIcon from '/src/assets/icons/compass.svg?react';
|
import CompassIcon from '/src/assets/icons/compass.svg?react';
|
||||||
|
import { STATIC_URL } from '../../constants';
|
||||||
|
|
||||||
export default function ClassCard({ img, path, children }) {
|
export default function ClassCard({ img, path, children }) {
|
||||||
return (
|
return (
|
||||||
@ -10,7 +11,7 @@ export default function ClassCard({ img, path, children }) {
|
|||||||
>
|
>
|
||||||
{img ? (
|
{img ? (
|
||||||
<img
|
<img
|
||||||
src={`${import.meta.env.VITE_STATIC_URL}${img}`}
|
src={`${STATIC_URL}${img}`}
|
||||||
alt="강의 이미지"
|
alt="강의 이미지"
|
||||||
className={styles.thumbnail}
|
className={styles.thumbnail}
|
||||||
/>
|
/>
|
||||||
|
@ -120,7 +120,7 @@ export default function LiveRoom() {
|
|||||||
controls={{ chat: false, leave: true, screenShare: role === '강사' }}
|
controls={{ chat: false, leave: true, screenShare: role === '강사' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ChatRoom />
|
<ChatRoom isTeacher={role === '강사'} />
|
||||||
</LayoutContextProvider>
|
</LayoutContextProvider>
|
||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
<ConnectionStateToast />
|
<ConnectionStateToast />
|
||||||
|
49
frontend/src/components/Quiz/Quiz.jsx
Normal file
49
frontend/src/components/Quiz/Quiz.jsx
Normal file
@ -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 (
|
||||||
|
<div className={styles.quiz}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1>{question}</h1>
|
||||||
|
{image && (
|
||||||
|
<img
|
||||||
|
src={`${STATIC_URL}${image}`}
|
||||||
|
alt="문제 이미지"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className={styles.choiceWrapper}>
|
||||||
|
{isChoice ? (
|
||||||
|
choices.map(({ id, num, content }) => (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
onClick={() => {
|
||||||
|
setAnswer(num);
|
||||||
|
setAnswers(num);
|
||||||
|
}}
|
||||||
|
className={`${styles.choice} ${answer === num ? styles.active : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.number}>{num}</span>
|
||||||
|
<span>{content}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
placeholder="답 입력"
|
||||||
|
onChange={(e) =>
|
||||||
|
setAnswers((prev) => prev.map((value, index) => (index === step ? e.target.value : value)))
|
||||||
|
}
|
||||||
|
className={styles.input}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
78
frontend/src/components/Quiz/Quiz.module.css
Normal file
78
frontend/src/components/Quiz/Quiz.module.css
Normal file
@ -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);
|
||||||
|
}
|
1
frontend/src/components/Quiz/index.js
Normal file
1
frontend/src/components/Quiz/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Quiz } from './Quiz';
|
45
frontend/src/components/QuizModal/QuizModal.jsx
Normal file
45
frontend/src/components/QuizModal/QuizModal.jsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className={styles.wrapper}
|
||||||
|
onClick={handleClick}
|
||||||
|
ref={wrapperRef}
|
||||||
|
>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<header className={styles.title}>
|
||||||
|
<div>퀴즈 선택</div>
|
||||||
|
<button onClick={closeModal}>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{quizSets.map((quizSet) => (
|
||||||
|
<li
|
||||||
|
key={quizSet.quizSetId}
|
||||||
|
onClick={() => {
|
||||||
|
console.log(quizSet.quizSetId);
|
||||||
|
startQuiz(quizSet.quizSetId);
|
||||||
|
closeModal();
|
||||||
|
}}
|
||||||
|
className={styles.quiz}
|
||||||
|
>
|
||||||
|
{quizSet.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
94
frontend/src/components/QuizModal/QuizModal.module.css
Normal file
94
frontend/src/components/QuizModal/QuizModal.module.css
Normal file
@ -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;
|
||||||
|
}
|
1
frontend/src/components/QuizModal/index.js
Normal file
1
frontend/src/components/QuizModal/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as QuizModal } from './QuizModal';
|
91
frontend/src/components/QuizSet/QuizSet.jsx
Normal file
91
frontend/src/components/QuizSet/QuizSet.jsx
Normal file
@ -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) => (
|
||||||
|
<Quiz
|
||||||
|
key={index}
|
||||||
|
step={index}
|
||||||
|
answers={answers.current}
|
||||||
|
setAnswers={(value) => {
|
||||||
|
answers.current = answers.current.map((v, i) => (i === index ? value : v));
|
||||||
|
}}
|
||||||
|
{...quiz}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
<div
|
||||||
|
key={Infinity}
|
||||||
|
className={styles.message}
|
||||||
|
>
|
||||||
|
퀴즈 종료
|
||||||
|
</div>,
|
||||||
|
];
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<div className={styles.message}>
|
||||||
|
<span>퀴즈를 시작합니다</span>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{QuizComponents[step]}
|
||||||
|
<div className={styles.progressBar} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
44
frontend/src/components/QuizSet/QuizSet.module.css
Normal file
44
frontend/src/components/QuizSet/QuizSet.module.css
Normal file
@ -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;
|
||||||
|
}
|
1
frontend/src/components/QuizSet/index.js
Normal file
1
frontend/src/components/QuizSet/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as QuizSet } from './QuizSet';
|
@ -1,6 +1,7 @@
|
|||||||
import BackIcon from '/src/assets/icons/back.svg?react';
|
import BackIcon from '/src/assets/icons/back.svg?react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styles from './QuizsetDetail.module.css';
|
import styles from './QuizsetDetail.module.css';
|
||||||
|
import { STATIC_URL } from '../../constants';
|
||||||
|
|
||||||
export default function QuizsetDetail({ topic, title, quizzes = [], onDelete, onEdit }) {
|
export default function QuizsetDetail({ topic, title, quizzes = [], onDelete, onEdit }) {
|
||||||
return (
|
return (
|
||||||
@ -23,7 +24,7 @@ export default function QuizsetDetail({ topic, title, quizzes = [], onDelete, on
|
|||||||
<div>질문 : {quiz.question}</div>
|
<div>질문 : {quiz.question}</div>
|
||||||
{quiz.image && (
|
{quiz.image && (
|
||||||
<img
|
<img
|
||||||
src={`${import.meta.env.VITE_STATIC_URL}${quiz.image}`}
|
src={`${STATIC_URL}${quiz.image}`}
|
||||||
alt="강의 이미지"
|
alt="강의 이미지"
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
/>
|
/>
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
export const API_URL = import.meta.env.VITE_API_URL;
|
export const API_URL = import.meta.env.VITE_API_URL;
|
||||||
export const ROOM_URL = import.meta.env.VITE_ROOM_URL;
|
export const ROOM_URL = import.meta.env.VITE_ROOM_URL;
|
||||||
export const CHAT_URL = import.meta.env.VITE_CHAT_URL;
|
export const CHAT_URL = import.meta.env.VITE_CHAT_URL;
|
||||||
|
export const STATIC_URL = import.meta.env.VITE_STATIC_URL;
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import instance from '../../utils/axios/instance';
|
import instance from '../../utils/axios/instance';
|
||||||
import { API_URL } from '../../constants';
|
import { API_URL } from '../../constants';
|
||||||
|
|
||||||
export function useQuizsets() {
|
export function useQuizsets() {
|
||||||
return useQuery({
|
return useSuspenseQuery({
|
||||||
queryKey: ['quizsetList'],
|
queryKey: ['quizsetList'],
|
||||||
queryFn: () => instance.get(`${API_URL}/quiz`),
|
queryFn: () => instance.get(`${API_URL}/quiz`),
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { chatClient } from '../../utils/chat/chatClient';
|
import { chatClient } from '../../utils/chat/chatClient';
|
||||||
import useBoundStore from '../../store';
|
import useBoundStore from '../../store';
|
||||||
|
import { useQuizsets } from '../api/useQuizsets';
|
||||||
|
|
||||||
const USER_ID = crypto.getRandomValues(new Uint32Array(1))[0];
|
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 userName = useBoundStore((state) => state.userName) ?? '익명';
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
const chatListRef = 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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -34,8 +48,12 @@ export default function useChatRoom(roomId) {
|
|||||||
client.onConnect = () => {
|
client.onConnect = () => {
|
||||||
client.subscribe(`/exchange/chat.exchange/*.room.${roomId}`, (response) => {
|
client.subscribe(`/exchange/chat.exchange/*.room.${roomId}`, (response) => {
|
||||||
const data = JSON.parse(response.body);
|
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 }]);
|
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]);
|
}, [client, roomId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
|
if (chatListRef.current) {
|
||||||
|
chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
|
||||||
|
}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -55,5 +75,9 @@ export default function useChatRoom(roomId) {
|
|||||||
inputRef,
|
inputRef,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
chatListRef,
|
chatListRef,
|
||||||
|
startQuiz,
|
||||||
|
quizSets,
|
||||||
|
quizSetId,
|
||||||
|
setQuizSetId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -33,8 +33,14 @@ export default function LivePage() {
|
|||||||
connect={true}
|
connect={true}
|
||||||
data-lk-theme="default"
|
data-lk-theme="default"
|
||||||
onDisconnected={() => {
|
onDisconnected={() => {
|
||||||
instance.post(`${API_URL}/video/deleteroom/${roomId}`).catch(() => {});
|
setTimeout(() => {
|
||||||
window.close();
|
instance
|
||||||
|
.post(`${API_URL}/video/deleteroom/${roomId}`)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LiveRoom />
|
<LiveRoom />
|
||||||
|
Loading…
Reference in New Issue
Block a user