feat: 라이브 수업 구현

This commit is contained in:
jhynsoo 2024-08-05 11:42:40 +09:00
parent e3667f1b73
commit ba6945de05
34 changed files with 515 additions and 461 deletions

View File

@ -8,6 +8,8 @@
"name": "edufocus",
"version": "0.0.0",
"dependencies": {
"@livekit/components-react": "^2.4.3",
"@livekit/components-styles": "^1.0.12",
"@stomp/stompjs": "^7.0.0",
"@tanstack/react-query": "^5.49.2",
"axios": "^1.7.2",
@ -905,6 +907,31 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.5.tgz",
"integrity": "sha512-8GrTWmoFhm5BsMZOTHeGD2/0FLKLQQHvO/ZmQga4tKempYRLz8aqJGqXVuQgisnMObq2YZ2SgkwctN1LOOxcqA==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.5"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.8.tgz",
"integrity": "sha512-kx62rP19VZ767Q653wsP1XZCGIirkE09E0QUGNYTM/ttbbQHqcGPdSfWFxUyyNLc/W6aoJRBajOSXhP6GXjC0Q==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.5"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.5.tgz",
"integrity": "sha512-sTcG+QZ6fdEUObICavU+aB3Mp8HY4n14wYHdxK4fXjPmv3PXZZeY5RaguJmGyeH/CJQhX3fqKUtS4qc1LoHwhQ==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@ -1009,6 +1036,55 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@livekit/components-core": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.11.2.tgz",
"integrity": "sha512-rXQ1OvyGe9gY8BCpH5FTr4Il17/sS/ecJQbG3PoOXAkQVl5JP965eqUPyKXZTdxNKlVLef00AygrO2pPArwOTA==",
"license": "Apache-2.0",
"dependencies": {
"@floating-ui/dom": "1.6.8",
"loglevel": "1.9.1",
"rxjs": "7.8.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@livekit/protocol": "^1.16.0",
"livekit-client": "^2.4.0",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-react": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.4.3.tgz",
"integrity": "sha512-XhCvwFvNjhBJcoQHIY4Hk6MBp7mM9q0n0i7sN/xK3fB1DSjkxIkpc7lh/+Pjqdu6F6OJT3MjwNFYnftqy6kcmw==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/components-core": "0.11.2",
"clsx": "2.1.1",
"usehooks-ts": "3.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@livekit/protocol": "^1.16.0",
"livekit-client": "^2.4.0",
"react": ">=18",
"react-dom": ">=18",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-styles": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.0.12.tgz",
"integrity": "sha512-Hsxkfq240w0tMPtkQTHQotpkYfIY4lhP2pzegvOIIV/nYxj8LeRYypUjxJpFw3s6jQcV/WQS7oCYmFQdy98Jtw==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/@livekit/protocol": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.19.1.tgz",
@ -2420,6 +2496,15 @@
"node": "*"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -4324,6 +4409,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -5244,7 +5335,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
@ -5873,6 +5963,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/usehooks-ts": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz",
"integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/vite": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz",

View File

@ -13,6 +13,8 @@
"test:run": "vitest run"
},
"dependencies": {
"@livekit/components-react": "^2.4.3",
"@livekit/components-styles": "^1.0.12",
"@stomp/stompjs": "^7.0.0",
"@tanstack/react-query": "^5.49.2",
"axios": "^1.7.2",

View File

@ -1,42 +1,112 @@
import styles from './ChatRoom.module.css';
import { useParams } from 'react-router-dom';
import SendIcon from '/src/assets/icons/send.svg?react';
import useChatRoom from '../../hooks/chat/useChatRoom';
import { cloneElement, useEffect, useRef } from 'react';
import { ChatEntry, useChat, useMaybeLayoutContext } from '@livekit/components-react';
export default function ChatRoom() {
const { roomId } = useParams();
const { messages, handleSubmit, inputRef, chatListRef } = useChatRoom(roomId);
export default function ChatRoom({ ...props }) {
const chatInputRef = useRef(null);
const ulRef = useRef(null);
const { send, chatMessages, isSending } = useChat();
const layoutContext = useMaybeLayoutContext();
const lastReadMsgAt = useRef(0);
async function handleChatSubmit(event) {
event.preventDefault();
if (chatInputRef.current && chatInputRef.current.value.trim() !== '') {
if (send) {
await send(chatInputRef.current.value);
chatInputRef.current.value = '';
chatInputRef.current.focus();
}
}
}
useEffect(() => {
if (ulRef) {
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight });
}
}, [ulRef, chatMessages]);
useEffect(() => {
if (!layoutContext || chatMessages.length === 0) {
return;
}
if (
layoutContext.widget.state?.showChat &&
chatMessages.length > 0 &&
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
) {
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp;
return;
}
const unreadMessageCount = chatMessages.filter(
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
).length;
const { widget } = layoutContext;
if (unreadMessageCount > 0 && widget.state?.unreadMessages !== unreadMessageCount) {
widget.dispatch?.({ msg: 'unread_msg', count: unreadMessageCount });
}
}, [chatMessages, layoutContext, layoutContext.widget]);
return (
<section className={styles.room}>
<div
{...props}
className="lk-chat"
>
<h2 className={styles.title}>채팅</h2>
<ol
className={styles.messageList}
ref={chatListRef}
<ul
className="lk-list lk-chat-messages"
ref={ulRef}
>
{messages.map?.((message) => (
<li
key={message.id}
className={message.isMine ? styles.my : styles.your}
>
<span className={styles.name}>{message.name}</span>
<span className={styles.bubble}>{message.text}</span>
</li>
))}
</ol>
{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 (
<ChatEntry
key={msg.id ?? idx}
hideName={hideName}
hideTimestamp={hideName === false ? false : hideTimestamp}
entry={msg}
/>
);
})}
</ul>
<form
action="POST"
onSubmit={handleSubmit}
className={styles.form}
className="lk-chat-form"
onSubmit={handleChatSubmit}
>
<input
className="lk-form-control lk-chat-form-input"
disabled={isSending}
ref={chatInputRef}
type="text"
ref={inputRef}
placeholder="메시지"
onInput={(ev) => ev.stopPropagation()}
onKeyDown={(ev) => ev.stopPropagation()}
onKeyUp={(ev) => ev.stopPropagation()}
/>
<button type="submit">
<button
type="submit"
className={`lk-button lk-chat-form-button ${styles.button}`}
disabled={isSending}
>
<SendIcon />
</button>
</form>
</section>
</div>
);
}

View File

@ -3,7 +3,7 @@
flex-direction: column;
justify-content: space-between;
position: relative;
width: 100%;
width: 400px;
height: 100%;
overflow-y: hidden;
}
@ -20,8 +20,9 @@
align-items: start;
gap: 12px;
list-style: none;
max-height: 100%;
margin: 0;
padding: 0 16px;
padding: 0 8px 8px;
white-space: nowrap;
overflow-x: hidden;
overflow-y: auto;
@ -91,8 +92,8 @@
align-items: center;
gap: 8px;
margin: 16px;
background-color: var(--background);
border: 1px solid var(--border-color);
background-color: black;
border: 1px solid var(--border-color-tertiary);
border-radius: 12px;
box-shadow: var(--shadow);
overflow: hidden;
@ -103,7 +104,7 @@
width: 100%;
border: none;
border-radius: 12px;
color: var(--text-color);
/* color: var(--text-color); */
font-size: 16px;
line-height: 1.4;
font-weight: 400;
@ -124,7 +125,11 @@
line-height: 1.4;
font-weight: 500;
cursor: pointer;
background-color: var(--background);
stroke: var(--text-color);
background-color: black;
stroke: white;
}
}
.button {
stroke: white;
}

View File

@ -1,15 +1,11 @@
import styles from './InfoEditForm.module.css';
import { useState } from 'react';
export default function InfoEditForm({ onSubmit }) {
export default function InfoEditForm() {
const [username, setUsername] = useState('');
const [useremail, setUseremail] = useState('');
return (
<form
onSubmit={(e) => onSubmit(e, username, useremail)}
className={styles.infoEditForm}
>
<form className={styles.infoEditForm}>
<p className={styles.textHeading}>이름 변경</p>
<div className={styles.inputBox}>
<label

View File

@ -1,22 +0,0 @@
import { useEffect, useRef } from 'react';
export default function LiveAudio({ track }) {
const audioRef = useRef(null);
useEffect(() => {
if (audioRef.current) {
track.attach(audioRef.current);
}
return () => {
track.detach();
};
}, [track]);
return (
<audio
ref={audioRef}
id={track.sid}
/>
);
}

View File

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

View File

@ -1,46 +1,99 @@
import styles from './LiveRoom.module.css';
import { isEqualTrackRef, isTrackReference } from '@livekit/components-core';
import {
CarouselLayout,
ConnectionStateToast,
ControlBar,
FocusLayout,
FocusLayoutContainer,
GridLayout,
LayoutContextProvider,
ParticipantTile,
RoomAudioRenderer,
useCreateLayoutContext,
usePinnedTracks,
useTracks,
} from '@livekit/components-react';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useRef } from 'react';
import ChatRoom from '../ChatRoom/ChatRoom';
import { LiveAudio } from '../LiveAudio';
import { LiveVideo } from '../LiveVideo';
import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator';
export default function LiveRoom({ room, localTrack, remoteTracks, leaveRoom, mainTrack }) {
return (
<>
<main>
{room ? (
<>
<div className={styles.videoWrapper}>
{localTrack && (
<LiveVideo
track={localTrack}
identity="나"
/>
)}
{remoteTracks.map((track) =>
track.trackPublication.kind === 'video' ? (
<LiveVideo
key={track.trackPublication.trackSid}
track={track.trackPublication.videoTrack}
/>
) : (
<LiveAudio
key={track.trackPublication.trackSid}
track={track.trackPublication.audioTrack}
/>
export default function LiveRoom() {
const lastAutoFocusedScreenShareTrack = useRef(null);
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
);
const layoutContext = useCreateLayoutContext();
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare);
const focusTrack = usePinnedTracks(layoutContext)?.[0];
const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
useEffect(() => {
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
lastAutoFocusedScreenShareTrack.current === null
) {
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: screenShareTracks[0] });
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0];
} else if (
lastAutoFocusedScreenShareTrack.current &&
!screenShareTracks.some(
(track) => track.publication.trackSid === lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
)
)}
) {
layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
lastAutoFocusedScreenShareTrack.current = null;
}
if (focusTrack && !isTrackReference(focusTrack)) {
const updatedFocusTrack = tracks.find(
(tr) => tr.participant.identity === focusTrack.participant.identity && tr.source === focusTrack.source
);
if (updatedFocusTrack !== focusTrack && isTrackReference(updatedFocusTrack)) {
layoutContext.pin.dispatch?.({ msg: 'set_pin', trackReference: updatedFocusTrack });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
// eslint-disable-next-line react-hooks/exhaustive-deps
screenShareTracks.map((ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`).join(),
focusTrack?.publication?.trackSid,
tracks,
]);
return (
<div className="lk-video-conference">
<LayoutContextProvider value={layoutContext}>
<div className="lk-video-conference-inner">
{!focusTrack ? (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
{mainTrack && <LiveVideo track={mainTrack} />}
</>
) : (
<LoadingIndicator fill />
<div className="lk-focus-layout-wrapper">
<FocusLayoutContainer>
<CarouselLayout tracks={carouselTracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</main>
<aside>
<ControlBar controls={{ chat: false, leave: false }} />
</div>
<ChatRoom />
<button onClick={leaveRoom}>나가기</button>
</aside>
</>
</LayoutContextProvider>
<RoomAudioRenderer />
<ConnectionStateToast />
</div>
);
}

View File

@ -1,11 +1,17 @@
.main {
display: flex;
flex-direction: column;
}
.videoWrapper {
flex-grow: 0;
flex: 0 0 auto;
display: flex;
overflow-x: auto;
height: 80px;
gap: 10px;
padding: 10px;
border-bottom: 1px solid var(--border-color);
box-sizing: border-box;
& > audio {
display: none;
@ -21,7 +27,41 @@
}
}
.myVideo {
border-radius: 12px;
border: 2px solid var(--border-color);
.mainContent {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
box-sizing: border-box;
& > video {
width: 100%;
height: calc(100vh - 208px);
object-fit: contain;
box-sizing: border-box;
}
}
.controlBar {
display: flex;
justify-content: space-between;
align-items: center;
height: 80px;
border-top: 1px solid var(--border-color);
box-sizing: border-box;
& > button {
background-color: var(--background-color-secondary);
color: var(--text-color-primary);
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-color-tertiary);
}
}
}

View File

@ -1,24 +0,0 @@
import styles from './LiveVideo.module.css';
import { useEffect, useRef } from 'react';
export default function LiveVideo({ track }) {
const videoRef = useRef(null);
useEffect(() => {
if (videoRef.current) {
track.attach(videoRef.current);
}
return () => {
track.detach();
};
}, [track]);
return (
<video
ref={videoRef}
id={track?.sid}
className={styles.video}
/>
);
}

View File

@ -1,36 +0,0 @@
.wrapper {
width: fit-content;
height: 100%;
position: relative;
border-radius: 10px;
overflow: hidden;
& > p {
z-index: 99;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
margin: 0;
padding: 0;
text-align: center;
opacity: 0;
background-color: var(--whiteOpacity600);
font-size: 12px;
line-height: 1.4;
color: var(--text-color);
font-weight: bold;
transition: opacity 0.2s;
}
&:hover > p {
opacity: 1;
}
}
.video {
max-width: 100%;
max-height: 100%;
object-fit: cover;
border-radius: 8px;
}

View File

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

View File

@ -1,9 +1,10 @@
import styles from './LoadingIndicator.module.css';
export default function LoadingIndicator({ fill = false }) {
export default function LoadingIndicator({ fill = false, label }) {
return fill ? (
<div className={styles.wrapper}>
<div className={styles.indicator} />
{label && <div className={styles.label}>{label}</div>}
</div>
) : (
<div className={styles.indicator} />

View File

@ -4,10 +4,26 @@
}
}
@keyframes wave {
0% {
rotate: -5deg;
}
50% {
rotate: 5deg;
}
100% {
rotate: -5deg;
}
}
.wrapper {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
width: 100%;
height: 100%;
margin: 0 auto;
@ -22,3 +38,11 @@
box-sizing: border-box;
animation: spin 2s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
}
.label {
font-size: 16px;
line-height: 1.4;
font-weight: 400;
color: var(--text-color);
animation: wave 1.5s ease infinite;
}

View File

@ -1,13 +1,13 @@
import { useState, useRef } from 'react';
import styles from './PasswordChangeForm.module.css';
export default function PasswordChangeForm({ onSubmit, onPwError = false }) {
// TODO: onPwError( )
export default function PasswordChangeForm() {
const [errorConfirmMessage, setErrorConfirmMessage] = useState(false);
const [errorSameMessage, setErrorSameMessage] = useState(false);
const currentPasswordRef = useRef('');
const newPasswordRef = useRef('');
const confirmPasswordRef = useRef('');
const userPassword = '1234';
const handleSubmit = (e) => {
e.preventDefault();
@ -15,15 +15,13 @@ export default function PasswordChangeForm({ onSubmit, onPwError = false }) {
const newPassword = newPasswordRef.current.value;
const confirmPassword = confirmPasswordRef.current.value;
if (currentPassword === userPassword) {
setErrorSameMessage(false);
} else {
setErrorSameMessage(true);
}
if (newPassword === confirmPassword) {
setErrorConfirmMessage(false);
onSubmit(currentPassword, newPassword, confirmPassword);
if (onPwError) {
setErrorSameMessage(true);
} else {
setErrorSameMessage(false);
}
} else {
setErrorConfirmMessage(true);
}

View File

@ -1,18 +1,15 @@
import { useState } from 'react';
import styles from './QuizCard.module.css';
export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
export default function QuizCard({ quiz, index, updateQuiz }) {
const [question, setQuestion] = useState(quiz.question || '');
const [answer, setAnswer] = useState(quiz.answer || '');
const [choices, setChoices] = useState(quiz.choices || []);
const [imageFile, setImageFile] = useState(quiz.imageFile || null);
const handleChoiceChange = (num, content) => {
const updatedChoices = choices.map((choice) =>
choice.num === num ? { ...choice, content } : choice
);
const updatedChoices = choices.map((choice) => (choice.num === num ? { ...choice, content } : choice));
setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, imageFile });
updateQuiz(index, { question, answer, choices: updatedChoices });
};
const handleAddChoice = () => {
@ -20,7 +17,7 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
const newChoice = { num: choices.length + 1, content: '' };
const updatedChoices = [...choices, newChoice];
setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, imageFile });
updateQuiz(index, { question, answer, choices: updatedChoices });
}
};
@ -28,29 +25,19 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
if (choices.length > 0) {
const updatedChoices = choices.slice(0, -1);
setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, imageFile });
updateQuiz(index, { question, answer, choices: updatedChoices });
}
};
const handleFileChange = (e) => {
const file = e.target.files[0] ?? null;
setImageFile(file);
updateQuiz(quiz.id, { ...quiz, question, answer, choices, imageFile: file });
};
return (
<div className={styles.card}>
<div className={styles.header}>
<span>퀴즈 생성 카드</span>
<span onClick={() => deleteQuiz(quiz.id)}>X</span> {/* id를 기반으로 삭제 */}
</div>
<label>질문</label>
<input
type="text"
value={question}
onChange={(e) => {
setQuestion(e.target.value);
updateQuiz(quiz.id, { ...quiz, question: e.target.value, answer, choices, imageFile });
updateQuiz(index, { question: e.target.value, answer, choices });
}}
placeholder="질문 내용을 입력하세요"
/>
@ -60,7 +47,7 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
value={answer}
onChange={(e) => {
setAnswer(e.target.value);
updateQuiz(quiz.id, { ...quiz, question, answer: e.target.value, choices, imageFile });
updateQuiz(index, { question, answer: e.target.value, choices });
}}
placeholder="정답을 입력하세요"
/>
@ -68,10 +55,18 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
<span>Tip: 선택지를 넣지 않는다면 단답형 문제가 됩니다</span>
</div>
<div className={styles.buttonsWrapper}>
<button type="button" onClick={handleAddChoice} className={styles.button}>
<button
type="button"
onClick={handleAddChoice}
className={styles.button}
>
선택지 추가하기
</button>
<button type="button" onClick={handlePopChoice} className={styles.removeButton}>
<button
type="button"
onClick={handlePopChoice}
className={styles.removeButton}
>
선택지 줄이기
</button>
</div>
@ -86,8 +81,6 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
/>
</div>
))}
<label>퀴즈 이미지</label>
<input type="file" accept=".png, .jpg, .jpeg" onChange={handleFileChange} />
</div>
);
}

View File

@ -8,12 +8,6 @@
gap: 8px;
}
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.buttonsWrapper {
display: flex;
flex-direction: row;

View File

@ -6,33 +6,32 @@ import BackIcon from '/src/assets/icons/back.svg?react';
import { Link } from 'react-router-dom';
export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) {
// TODO:
const [title, setTitle] = useState('');
const [quizzes, setQuizzes] = useState([]);
const [quizId, setQuizId] = useState(0);
const [imageFile, setImageFile] = useState(null);
const handleAddQuiz = () => {
setQuizzes([
...quizzes,
{ id: quizId, question: '', answer: '', choices: [], imageFile: null },
]);
setQuizId(quizId + 1);
setQuizzes([...quizzes, { question: '', answer: '', choices: [] }]);
};
const updateQuiz = (id, updatedQuiz) => {
const updatedQuizzes = quizzes.map((quiz) =>
quiz.id === id ? updatedQuiz : quiz
);
const updateQuiz = (index, updatedQuiz) => {
const updatedQuizzes = quizzes.map((quiz, i) => (i === index ? updatedQuiz : quiz));
setQuizzes(updatedQuizzes);
};
const deleteQuiz = (id) => {
setQuizzes(quizzes.filter((quiz) => quiz.id !== id));
const handleFileChange = (e) => {
const file = e.target.files?.[0];
setImageFile(file);
};
return (
<div className={styles.quizsetForm}>
<header className={styles.header}>
<Link to={to} className={styles.goBack}>
<Link
to={to}
className={styles.goBack}
>
<BackIcon />
<span>{headerTitle}</span>
</Link>
@ -40,7 +39,7 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) {
</header>
<form
className={styles.form}
onSubmit={(e) => onSubmit(e, title, quizzes)}
onSubmit={(e) => onSubmit(e, title, quizzes, imageFile)}
>
<input
type="text"
@ -48,12 +47,12 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) {
onChange={(e) => setTitle(e.target.value)}
placeholder="퀴즈셋 제목을 입력해주세요"
/>
{quizzes.map((quiz) => (
{quizzes.map((quiz, index) => (
<QuizCard
key={quiz.id}
key={index}
quiz={quiz}
index={index}
updateQuiz={updateQuiz}
deleteQuiz={deleteQuiz}
/>
))}
<button
@ -63,7 +62,16 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit }) {
>
퀴즈 추가하기
</button>
<button type="submit" className={styles.button}>
<label>퀴즈 이미지</label>
<input
type="file"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
/>
<button
type="submit"
className={styles.button}
>
<EditIcon />
<div>제출</div>
</button>

View File

@ -2,7 +2,7 @@ import BackIcon from '/src/assets/icons/back.svg?react';
import { Link } from 'react-router-dom';
import styles from './QuizsetDetail.module.css';
export default function QuizsetDetail({ topic, title, quizzes = [], onDelete }) {
export default function QuizsetDetail({ topic, title }) {
return (
<div className={styles.quizsetDetail}>
<header className={styles.header}>
@ -17,24 +17,6 @@ export default function QuizsetDetail({ topic, title, quizzes = [], onDelete })
<h1 className={styles.title}>{title}</h1>
</div>
</header>
<div>
{quizzes.map((quiz, index) => (
<div key={index}>
<div>질문 : {quiz.question}</div>
<img
src={quiz.image}
alt="강의 이미지"
/>
<div>정답이 응답에 없네요</div>
</div>
))}
</div>
<button
type="button"
onClick={onDelete}
>
퀴즈셋 삭제
</button>
</div>
);
}

View File

@ -49,29 +49,15 @@ export function useAuth() {
.post(`${API_URL}/user/logout`)
.then((response) => {
console.log(response);
})
.catch((e) => {
console.log(e);
})
.finally(() => {
setUserType(null);
setToken(null);
})
.catch((e) => console.log(e));
});
};
const updateInfo = (name, email) => {
const infoBody = {
name,
email,
};
return instance.put(`${API_URL}/user/updateinfo`, infoBody);
};
const updatePassword = (currentPw, newPw, newPwCheck) => {
const passwordBody = {
currentPassword: currentPw,
newPassword: newPw,
newPasswordCheck: newPwCheck,
};
console.log(passwordBody);
return instance.put(`${API_URL}/user/updatepassword`, passwordBody);
};
return { login, logout, userRegister, updateInfo, updatePassword };
return { login, logout, userRegister };
}

View File

@ -1,10 +0,0 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQuizsetDelete() {
const quizsetDelete = (quizsetId) => {
return instance.delete(`${API_URL}/quiz/teacher/${quizsetId}`);
};
return { quizsetDelete };
}

View File

@ -1,10 +0,0 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQuizsetEdit() {
const quizsetEdit = (quizsetId, quizsetObject) => {
return instance.put(`${API_URL}/lecture/${quizsetId}`, quizsetObject);
};
return { quizsetEdit };
}

View File

@ -47,9 +47,7 @@ export default function useChatRoom(roomId) {
}, [client, roomId]);
useEffect(() => {
if (chatListRef.current.scrollHeight - chatListRef.current.scrollTop - chatListRef.current.clientHeight < 200) {
chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
}
}, [messages]);
return {

View File

@ -1,70 +0,0 @@
import { Room, RoomEvent } from 'livekit-client';
import { useCallback, useState } from 'react';
import { API_URL, ROOM_URL } from '../../constants';
import instance from '../../utils/axios/instance';
export default function useRoom(roomId) {
const [room, setRoom] = useState(null);
const [localTrack, setLocalTrack] = useState(null);
const [remoteTracks, setRemoteTracks] = useState([]);
const [mainTrack, setMainTrack] = useState(null);
const generateToken = useCallback(async () => {
await instance.post(`${API_URL}/video/makeroom/${roomId}`);
const { data } = await instance.post(`${API_URL}/video/joinroom/${roomId}`);
return data.token;
}, [roomId]);
const leaveRoom = useCallback(async () => {
console.log('leave room');
await room?.disconnect();
setRoom(null);
setLocalTrack(null);
setRemoteTracks([]);
}, [room]);
const joinRoom = useCallback(async () => {
const token = await generateToken();
const room = new Room();
room.prepareConnection(ROOM_URL, token);
room.on(RoomEvent.TrackSubscribed, (_track, publication, participant) => {
try {
const identity = JSON.parse(participant.identity);
const isTeacher = identity.role.startsWith('강사');
if (isTeacher) {
setMainTrack(publication.videoTrack);
}
} catch (e) {
console.log('not json');
} finally {
setRemoteTracks((prev) => [
...prev,
{
trackPublication: publication,
participantIdentity: participant.identity,
},
]);
}
});
room.on(RoomEvent.TrackUnsubscribed, (_track, publication) => {
console.log('unsubscribe remote');
setRemoteTracks((prev) => prev.filter((track) => track.trackPublication !== publication));
});
try {
console.log(token);
await room.connect(ROOM_URL, token);
await room.localParticipant.enableCameraAndMicrophone();
setLocalTrack(room.localParticipant.videoTrackPublications.values().next().value.videoTrack);
setRoom(room);
} catch (error) {
await leaveRoom();
}
}, [generateToken, leaveRoom]);
return { room, joinRoom, leaveRoom, localTrack, remoteTracks, mainTrack };
}

View File

@ -17,12 +17,13 @@ export default function LectureCreatePage() {
};
return (
<div>
<h1>강의 생성</h1>
<LectureForm
title={'강의 홈'}
topic={'강의 생성'}
to={'..'}
title={'강의 생성'}
onSubmit={handleSubmit}
onCreate={true}
/>
</div>
);
}

View File

@ -21,6 +21,7 @@ export default function LecutreEditPage() {
};
return (
<div>
<LectureForm
initialValues={initialData}
onSubmit={handleSubmit}
@ -28,5 +29,6 @@ export default function LecutreEditPage() {
topic={'강의 수정'}
to={'..'}
/>
</div>
);
}

View File

@ -1,25 +1,41 @@
import { LiveRoom } from '../../components/LiveRoom';
import { useParams } from 'react-router-dom';
import useRoom from '../../hooks/live/useRoom';
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
import { LiveKitRoom } from '@livekit/components-react';
import instance from '../../utils/axios/instance';
import { API_URL, ROOM_URL } from '../../constants';
import useBoundStore from '../../store';
import '@livekit/components-styles';
export default function LivePage() {
const { roomId } = useParams();
const { room, joinRoom, localTrack, remoteTracks, mainTrack, leaveRoom } = useRoom(roomId);
const generateToken = useCallback(async () => {
await instance.post(`${API_URL}/video/makeroom/${roomId}`);
const { data } = await instance.post(`${API_URL}/video/joinroom/${roomId}`);
return data.token;
}, [roomId]);
const liveToken = useBoundStore((state) => state.liveToken);
useEffect(() => {
if (!room) {
joinRoom();
if (!liveToken) {
generateToken().then((token) => {
useBoundStore.setState({ liveToken: token });
});
}
}, [joinRoom, room]);
}, [generateToken, liveToken]);
return (
<LiveRoom
room={room}
localTrack={localTrack}
remoteTracks={remoteTracks}
leaveRoom={leaveRoom}
mainTrack={mainTrack}
/>
liveToken && (
<LiveKitRoom
token={liveToken}
serverUrl={ROOM_URL}
connect={true}
data-lk-theme="default"
>
<LiveRoom />
</LiveKitRoom>
)
);
}

View File

@ -1,15 +1,5 @@
import { InfoEditForm } from '../../components/InfoEditForm';
import { useAuth } from '../../hooks/api/useAuth';
export default function MyInfoChangePage() {
const { updateInfo } = useAuth();
const handleSubmit = async (e, username, useremail) => {
e.preventDefault();
await updateInfo(username, useremail)
.then((res) => console.log(res))
.catch((err) => console.log(err));
};
return <InfoEditForm onSubmit={handleSubmit} />;
return <InfoEditForm />;
}

View File

@ -1,12 +1,5 @@
import { PasswordChangeForm } from '../../components/PasswordChangeForm';
import { useAuth } from '../../hooks/api/useAuth';
export default function PasswordChangePage() {
// TODO: 400
const { updatePassword } = useAuth();
const handleSubmit = async (currentPw, newPw, newPwCheck) => {
console.log(currentPw, newPw);
await updatePassword(currentPw, newPw, newPwCheck);
};
return <PasswordChangeForm onSubmit={handleSubmit} />;
return <PasswordChangeForm />;
}

View File

@ -1,25 +1,11 @@
import { useQuizsetDetail } from '../../hooks/api/useQuizsetDetail';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { QuizsetDetail } from '../../components/QuizsetDetail';
import { useQuizsetDelete } from '../../hooks/api/useQuizsetDelete';
export default function QuizsetDetailPage() {
const navigate = useNavigate();
const { quizsetId } = useParams();
const { quizsetDelete } = useQuizsetDelete();
const { data } = useQuizsetDetail(quizsetId);
const quizset = data.data;
export default function QuizsetListPage() {
const { lectureId } = useParams();
const { data } = useQuizsetDetail(lectureId);
const quizset = data?.data ?? [];
console.log(quizset);
const handleDelete = async () => {
await quizsetDelete(quizsetId);
navigate('..');
};
return (
<QuizsetDetail
topic={'퀴즈 목록'}
title={quizset.title}
quizzes={quizset.quizzes}
onDelete={handleDelete}
/>
);
return <QuizsetDetail title={quizset.title} />;
}

View File

@ -1,47 +1,26 @@
import { QuizsetForm } from '../../components/QuizForm';
import { useQuizsetWrite } from '../../hooks/api/useQuizsetWrite';
import { useNavigate } from 'react-router-dom';
export default function QuizsetWritePage() {
const navigate = useNavigate();
// TODO: lecture
const { quizsetWrite } = useQuizsetWrite();
const handleSubmit = async (e, title, quizzes) => {
const handleSubmit = async (e, title, quizzes, imageFile = null) => {
e.preventDefault();
console.log(quizzes)
const images = [];
const quizContents = [];
quizzes.forEach((quiz) => {
const { imageFile, ...quizData } = quiz;
images.push(imageFile);
quizContents.push(quizData);
});
const quizsetObject = {
title,
quizzes: quizContents,
quizzes,
};
console.log(quizsetObject);
console.log(imageFile);
const formData = new FormData();
formData.append(
'quizSetCreateRequest',
new Blob([JSON.stringify(quizsetObject)], { type: 'application/json' })
);
images.forEach((imageFile) => {
formData.append('quizSetCreateRequest', new Blob([JSON.stringify(quizsetObject)], { type: 'application/json' }));
if (imageFile) {
formData.append('images', imageFile);
} else {
formData.append('images', new Blob([''], { type: 'image/jpg' }));
formData.append('image', imageFile);
}
});
await quizsetWrite(formData);
navigate('..');
const response = await quizsetWrite(formData);
console.log(response);
};
return (
<QuizsetForm
onSubmit={handleSubmit}

View File

@ -3,6 +3,7 @@ import { userTypeSlice } from './userTypeSlice';
import { tokenSlice } from './tokenSlice';
import { userNameSlice } from './userNameSlice';
import { persist } from 'zustand/middleware';
import { liveSlice } from './liveSlice';
const useBoundStore = create(
persist(
@ -10,6 +11,7 @@ const useBoundStore = create(
...userTypeSlice(...a),
...tokenSlice(...a),
...userNameSlice(...a),
...liveSlice(...a),
}),
{ name: 'bound-store' }
)

View File

@ -0,0 +1,4 @@
export const liveSlice = (set) => ({
liveToken: null,
setLiveToken: (liveToken) => set({ liveToken }),
});

View File

@ -3,7 +3,6 @@ import useBoundStore from '../../store';
const instance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 1000,
headers: {
'Content-type': 'application/json;charset=utf-8',
},
@ -12,6 +11,7 @@ const instance = axios.create({
instance.interceptors.request.use((config) => {
const accessToken = useBoundStore.getState().token;
console.log(accessToken);
if (accessToken) {
config.headers.Authorization = `${accessToken}`;
@ -23,7 +23,7 @@ instance.interceptors.request.use((config) => {
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response.status !== 401) {
if (error.response.status !== 401 || error.request.responseURL.includes('/user/refresh')) {
return Promise.reject(error);
}
@ -40,7 +40,7 @@ instance.interceptors.response.use(
return instance(error.config);
})
.catch((error) => {
useBoundStore.setState({ token: null });
useBoundStore.setState({ token: null, userType: null });
console.log(error);
console.log('---로그아웃----');
// TODO: redirect to home