feat: 퀴즈 추가

This commit is contained in:
jhynsoo 2024-08-06 17:27:08 +09:00
parent 03ec142ec3
commit 72850444ed
19 changed files with 586 additions and 63 deletions

View 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

View File

@ -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
)}
</>
); );
} }

View File

@ -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;
}

View File

@ -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}
/> />

View File

@ -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 />

View 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>
);
}

View 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);
}

View File

@ -0,0 +1 @@
export { default as Quiz } from './Quiz';

View 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>
);
}

View 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;
}

View File

@ -0,0 +1 @@
export { default as QuizModal } from './QuizModal';

View 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} />
</>
)}
</>
);
}

View 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;
}

View File

@ -0,0 +1 @@
export { default as QuizSet } from './QuizSet';

View File

@ -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}
/> />

View File

@ -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;

View File

@ -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`),
}); });

View File

@ -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,
}; };
} }

View File

@ -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 />