Merge branch 'frontend' into 'fe/studentReport'

# Conflicts:
#   frontend/src/Router.jsx
This commit is contained in:
조민우 2024-08-07 16:06:05 +09:00
commit 04df5b2ccd
42 changed files with 431 additions and 295 deletions

View File

@ -3,9 +3,9 @@ import { createBrowserRouter } from 'react-router-dom';
import PageLayout from './components/Layout/PageLayout'; import PageLayout from './components/Layout/PageLayout';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import NotFoundPage from './pages/NotFoundPage'; import NotFoundPage from './pages/NotFoundPage';
import { lazy } from 'react'; import { lazy, Suspense } from 'react';
import MyPageLayout from './components/Layout/MyPageLayout'; import MyPageLayout from './components/Layout/MyPageLayout';
import LivePage from './pages/LivePage'; // import LivePage from './pages/LivePage';
import ErrorPage from './pages/ErrorPage'; import ErrorPage from './pages/ErrorPage';
import { LectureLayout } from './components/Layout'; import { LectureLayout } from './components/Layout';
@ -38,6 +38,7 @@ const FreeboardDetailPage = lazy(async () => await import('./pages/FreeboardDeta
const EditFreeboardPage = lazy(async () => await import('./pages/EditFreeboardPage')); const EditFreeboardPage = lazy(async () => await import('./pages/EditFreeboardPage'));
const PasswordResetAuthPage = lazy(async () => await import('./pages/PasswordResetAuthPage')); const PasswordResetAuthPage = lazy(async () => await import('./pages/PasswordResetAuthPage'));
const StudentReportPage = lazy(async () => await import('./pages/StudentReportPage')); const StudentReportPage = lazy(async () => await import('./pages/StudentReportPage'));
const LivePage = lazy(async () => await import('./pages/LivePage'));
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -46,7 +47,11 @@ const router = createBrowserRouter([
}, },
{ {
path: 'live/:roomId', path: 'live/:roomId',
element: <LivePage />, element: (
<Suspense fallback={<></>}>
<LivePage />
</Suspense>
),
}, },
{ {
path: '', path: '',

View File

@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
import styles from './ArticleDetail.module.css'; import styles from './ArticleDetail.module.css';
import ArticleDetailAnswer from './ArticleDetailAnswer/ArticleDetailAnswer'; import ArticleDetailAnswer from './ArticleDetailAnswer/ArticleDetailAnswer';
import ArticleDetailAnswerInput from './ArticleDetailAnswer/ArticleDetailAnswerInput'; import ArticleDetailAnswerInput from './ArticleDetailAnswer/ArticleDetailAnswerInput';
import EditIcon from '/src/assets/icons/edit.svg?react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
export default function ArticleDetail({ topic, title, author = null, content, answer = null, onDelete, isQna = true }) { export default function ArticleDetail({ topic, title, author = null, content, answer = null, onDelete, isQna = true }) {
@ -44,22 +43,22 @@ export default function ArticleDetail({ topic, title, author = null, content, an
{author && <span className={styles.author}>{author}</span>} {author && <span className={styles.author}>{author}</span>}
</div> </div>
</div> </div>
<div className={styles.actionGroup}>
<Link <Link
type="button" className={styles.edit}
className={styles.editButton}
to={'edit'} to={'edit'}
state={{ title: title, content: content, answer: answer }} state={{ title: title, content: content, answer: answer }}
> >
<EditIcon className={styles.icon} /> 수정
<span>수정하기</span>
</Link> </Link>
<button <button
type="button" type="button"
className={styles.deleteButton} className={styles.delete}
onClick={onDelete} onClick={onDelete}
> >
삭제하기 삭제
</button> </button>
</div>
</header> </header>
<div> <div>
<p className={styles.content}>{content}</p> <p className={styles.content}>{content}</p>

View File

@ -13,7 +13,7 @@
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: start; align-items: end;
} }
.headerInside { .headerInside {
@ -60,34 +60,26 @@
stroke: var(--text-color); stroke: var(--text-color);
} }
.editButton { .actionGroup {
display: flex; display: flex;
align-items: center; align-items: end;
gap: 8px; gap: 20px;
padding: 12px;
background: var(--background);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
font-weight: 700;
color: var(--text-color); color: var(--text-color);
}
.edit,
.delete {
padding: 0;
margin: 0;
border: none;
background-color: var(--background);
color: var(--text-color-tertiary);
font-size: 16px;
line-height: 1.4;
font-weight: 500;
cursor: pointer; cursor: pointer;
} }
.deleteButton { .delete {
display: flex; color: var(--error-color);
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--error-color);
background-color: var(--error-color);
color: var(--on-primary);
stroke: var(--error-color);
font-size: 16px;
line-height: 1.4;
font-weight: 700;
align-self: end;
border-radius: 8px;
cursor: pointer;
} }

View File

@ -15,28 +15,29 @@ export default function ArticleDetailAnswer({ answer, onEditClick, onDeleteSubmi
}; };
return ( return (
<>
<section className={styles.answer}> <section className={styles.answer}>
<div className={styles.answerHeader}> <div className={styles.answerHeader}>
<ReplyIcon /> <div className={styles.author}>
<div className={styles.author}>선생님의 답변</div> <ReplyIcon /> <span>선생님의 답변</span>
</div> </div>
<p className={styles.content}>{answer}</p> <div className={styles.actionGroup}>
<button <button
type="button" type="button"
className={styles.deleteButton} className={styles.edit}
onClick={handleDeleteSubmit}
>
<div>삭제</div>
</button>
<button
type="button"
className={styles.editButton}
onClick={onEditClick} onClick={onEditClick}
> >
수정 수정
</button> </button>
<button
type="button"
className={styles.delete}
onClick={handleDeleteSubmit}
>
<div>삭제</div>
</button>
</div>
</div>
<p className={styles.content}>{answer}</p>
</section> </section>
</>
); );
} }

View File

@ -1,7 +1,7 @@
.answer { .answer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 12px;
width: 100%; width: 100%;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 16px; border-radius: 16px;
@ -11,17 +11,26 @@
.answerHeader { .answerHeader {
display: flex; display: flex;
gap: 4px; justify-content: space-between;
color: var(--text-color-secondary); color: var(--text-color-secondary);
stroke: var(--text-color-secondary); stroke: var(--text-color-secondary);
} }
.author { .author {
align-self: start;
display: flex;
align-items: center;
gap: 4px;
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
line-height: 1.4; line-height: 1.4;
} }
.actionGroup {
display: flex;
gap: 12px;
}
.content { .content {
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
@ -30,36 +39,19 @@
color: var(--text-color); color: var(--text-color);
} }
.editButton { .edit,
display: flex; .delete {
align-items: center; padding: 0;
gap: 8px; margin: 0;
padding: 12px 16px; border: none;
border: 1px solid var(--primary-color); background-color: var(--background);
background-color: var(--primary-color); font-size: 14px;
color: var(--on-primary);
stroke: var(--on-primary);
font-size: 16px;
line-height: 1.4; line-height: 1.4;
font-weight: 700; font-weight: 500;
align-self: end; color: var(--text-color-tertiary);
border-radius: 8px;
cursor: pointer; cursor: pointer;
} }
.deleteButton { .delete {
display: flex; color: var(--error-color);
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--error-color);
background-color: var(--error-color);
color: var(--on-primary);
stroke: var(--on-primary);
font-size: 16px;
line-height: 1.4;
font-weight: 700;
align-self: end;
border-radius: 8px;
cursor: pointer;
} }

View File

@ -3,6 +3,8 @@ import styles from './ArticlePreview.module.css';
import RightIcon from '/src/assets/icons/right.svg?react'; import RightIcon from '/src/assets/icons/right.svg?react';
export default function ArticlePreview({ to, title, contents = [] }) { export default function ArticlePreview({ to, title, contents = [] }) {
const hasContents = contents.length > 0;
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<Link <Link
@ -13,7 +15,8 @@ export default function ArticlePreview({ to, title, contents = [] }) {
<RightIcon /> <RightIcon />
</Link> </Link>
<div className={styles.main}> <div className={styles.main}>
{contents.map?.((content) => { {hasContents ? (
contents.map?.((content) => {
return ( return (
<Link <Link
to={`${to}/${content.id}`} to={`${to}/${content.id}`}
@ -23,7 +26,10 @@ export default function ArticlePreview({ to, title, contents = [] }) {
{content.title} {content.title}
</Link> </Link>
); );
})} })
) : (
<div className={styles.empty}>아직 작성된 글이 없습니다.</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -35,3 +35,13 @@
line-height: 1.4; line-height: 1.4;
font-weight: 400; font-weight: 400;
} }
.empty {
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0;
color: var(--text-color-tertiary);
font-size: 16px;
line-height: 1.2;
}

View File

@ -18,7 +18,9 @@ export default function ArticleBoard({ title, canCreate, children }) {
</Link> </Link>
)} )}
</div> </div>
<div className={styles.article}>{children}</div> <div className={styles.article}>
{children ? children : <div className={styles.empty}>표시할 내용이 없습니다.</div>}
</div>
</div> </div>
); );
} }

View File

@ -47,3 +47,13 @@
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
} }
.empty {
display: flex;
justify-content: center;
align-items: center;
padding: 60px 0;
color: var(--text-color-tertiary);
font-size: 16px;
line-height: 1.2;
}

View File

@ -69,7 +69,7 @@ export default function ChatRoom({ isTeacher, ...props }) {
<> <>
<div <div
{...props} {...props}
className={`lk-chat ${wsChat.quizSetId ? styles.none : ''}`} className={`lk-chat ${wsChat.quizSetInfo ? styles.none : ''}`}
> >
<header className={styles.header}> <header className={styles.header}>
<h2 className={styles.title}>채팅</h2> <h2 className={styles.title}>채팅</h2>
@ -131,15 +131,16 @@ export default function ChatRoom({ isTeacher, ...props }) {
</button> </button>
</form> </form>
</div> </div>
{wsChat.quizSetId && ( {wsChat.quizSetInfo && (
<div className="lk-chat"> <div className="lk-chat">
<header className={styles.header}> <header className={styles.header}>
<h2 className={styles.title}>퀴즈</h2> <h2 className={styles.title}>퀴즈</h2>
</header> </header>
<Suspense fallback={<></>}> <Suspense fallback={<></>}>
<QuizSet <QuizSet
quizSetId={wsChat.quizSetId} quizSetId={wsChat.quizSetInfo[0]}
finish={() => wsChat.setQuizSetId(null)} reportSetId={wsChat.quizSetInfo[1]}
finish={() => wsChat.setQuizSetInfo(null)}
/> />
</Suspense> </Suspense>
</div> </div>

View File

@ -19,3 +19,7 @@
stroke: var(--text-color); stroke: var(--text-color);
box-sizing: border-box; box-sizing: border-box;
} }
img.thumbnail {
border: 1px solid var(--border-color);
}

View File

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
export default function Countdown({ seconds }) {
// eslint-disable-next-line no-undef
const [remainedTime, setRemainedTime] = useState(seconds);
useEffect(() => {
const interval = setInterval(() => {
setRemainedTime((prev) => {
if (prev === 0) {
clearInterval(interval);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [remainedTime]);
return <span>{remainedTime}</span>;
}

View File

@ -26,12 +26,6 @@ export default function Header() {
/> />
</Link> </Link>
</li> </li>
<li>
<Link to={'/'}>전체 강의</Link>
</li>
<li>
<Link to={'/'}>수강중인 강의</Link>
</li>
<li> <li>
<Link <Link
to={'/live/1'} to={'/live/1'}

View File

@ -1,3 +1,4 @@
import styles from './LectureLayout.module.css';
import { Outlet, useParams } from 'react-router-dom'; import { Outlet, useParams } from 'react-router-dom';
import LectureHeader from '../LectureHeader/LectureHeader'; import LectureHeader from '../LectureHeader/LectureHeader';
import { SideBar, SideLink, SideItem } from '../SideBar'; import { SideBar, SideLink, SideItem } from '../SideBar';
@ -18,9 +19,11 @@ export default function LectureLayout() {
const lecture = data?.data; const lecture = data?.data;
console.log(lecture); console.log(lecture);
const userType = useBoundStore((state) => state.userType); const userType = useBoundStore((state) => state.userType);
const handleDelete = async () => { const handleDelete = () => {
await lectureDelete(lectureId); confirm('강의를 삭제할까요??') &&
lectureDelete(lectureId).then(() => {
navigate('..'); navigate('..');
});
}; };
const lectureData = { const lectureData = {
title: lecture.title, title: lecture.title,
@ -61,13 +64,24 @@ export default function LectureLayout() {
name="수강생" name="수강생"
sub="총 12명" sub="총 12명"
/> />
</SideBar>
)}
{userType === 'teacher' && (
<SideBar title={'강의 정보 관리'}>
<SideLink <SideLink
to={'edit'} to={'edit'}
state={lectureData} state={lectureData}
> >
강의 정보 수정 강의 정보 수정
</SideLink> </SideLink>
<button onClick={handleDelete}>강의 삭제</button> <li>
<span
onClick={handleDelete}
className={styles.delete}
>
강의 삭제
</span>
</li>
</SideBar> </SideBar>
)} )}
{userType === 'student' && ( {userType === 'student' && (

View File

@ -0,0 +1,7 @@
.delete {
font-size: 16px;
line-height: 1.4;
font-weight: 400;
color: var(--error-color);
cursor: pointer;
}

View File

@ -2,6 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
min-width: 1360px;
min-height: 100vh; min-height: 100vh;
} }

View File

@ -8,21 +8,39 @@ export default function LectureEnroll({ userName, enrollid, onDelete }) {
const handleAccept = async (e) => { const handleAccept = async (e) => {
e.preventDefault(); e.preventDefault();
if (!confirm('수강신청을 승인하시겠습니까?')) {
return;
}
await lectureEnrollAccept(enrollid); await lectureEnrollAccept(enrollid);
onDelete(enrollid); onDelete(enrollid);
}; };
const handleCancel = async (e) => { const handleCancel = async (e) => {
e.preventDefault(); e.preventDefault();
if (!confirm('수강신청을 거절하시겠습니까?')) {
return;
}
await lectureEnrollCancel(enrollid); await lectureEnrollCancel(enrollid);
onDelete(enrollid); onDelete(enrollid);
}; };
return ( return (
<div className={styles.enrollLink}> <div className={styles.enrollLink}>
<p>{userName}</p> <span>{userName}</span>
<button onClick={handleAccept}>등록</button> <div className={styles.buttonWrapper}>
<button onClick={handleCancel}>삭제</button> <button
onClick={handleAccept}
className={styles.accept}
>
등록
</button>
<button
onClick={handleCancel}
className={styles.reject}
>
삭제
</button>
</div>
</div> </div>
); );
} }

View File

@ -1,13 +1,44 @@
.enrollLink { .enrollLink {
border-radius: 8px;
width: 100%;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
box-sizing: border-box; align-items: center;
border-radius: 8px;
width: 100%;
padding: 16px 20px; padding: 16px 20px;
transition: background-color 0.25s; box-sizing: border-box;
} }
.enrollLink:hover { .buttonWrapper {
background-color: var(--background-secondary); display: flex;
gap: 8px;
}
.accept,
.reject {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid var(--border-color);
background-color: var(--background);
font-size: 14px;
font-weight: 500;
line-height: 1.4;
cursor: pointer;
transition:
border-color 0.1s,
color 0.1s;
}
.accept:hover {
border-color: var(--info-color);
color: var(--info-color);
}
.reject:hover {
border-color: var(--error-color);
color: var(--error-color);
} }

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { STATIC_URL } from '../../constants'; import { STATIC_URL } from '../../constants';
import styles from './Quiz.module.css'; import styles from './Quiz.module.css';
export default function Quiz({ question, step, image, choices = [], setAnswers }) { export default function Quiz({ question, image, choices = [], setAnswers }) {
const [answer, setAnswer] = useState(null); const [answer, setAnswer] = useState(null);
const isChoice = choices.length > 0; const isChoice = choices.length > 0;
@ -37,9 +37,9 @@ export default function Quiz({ question, step, image, choices = [], setAnswers }
type="text" type="text"
autoFocus autoFocus
placeholder="답 입력" placeholder="답 입력"
onChange={(e) => onChange={(e) => {
setAnswers((prev) => prev.map((value, index) => (index === step ? e.target.value : value))) setAnswers(e.target.value);
} }}
className={styles.input} className={styles.input}
/> />
)} )}

View File

@ -1,14 +1,13 @@
import styles from './QuizSet.module.css'; import styles from './QuizSet.module.css';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import Quiz from '../Quiz/Quiz'; import Quiz from '../Quiz/Quiz';
import { useParams } from 'react-router-dom';
import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator'; import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator';
import instance from '../../utils/axios/instance'; import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants'; import { API_URL } from '../../constants';
import { useStudentQuizsetDetail } from '../../hooks/api/useStudentQuizsetDetail'; import { useStudentQuizsetDetail } from '../../hooks/api/useStudentQuizsetDetail';
import Countdown from '../Countdown/Countdown';
export default function QuizSet({ quizSetId, finish }) { export default function QuizSet({ quizSetId, reportSetId, finish }) {
const { roomId } = useParams();
const [step, setStep] = useState(null); const [step, setStep] = useState(null);
const { data } = useStudentQuizsetDetail(quizSetId); const { data } = useStudentQuizsetDetail(quizSetId);
const quizSetData = data?.data; const quizSetData = data?.data;
@ -17,15 +16,17 @@ export default function QuizSet({ quizSetId, finish }) {
const interval = useRef(null); const interval = useRef(null);
const submit = useCallback( const submit = useCallback(
(data) => { (data) => {
instance.post(`${API_URL}/report/submit/${roomId}/quizset/${quizSetId}`, data).catch(() => {}); const requestData = {
answer: data,
};
instance.post(`${API_URL}/report/submit/${reportSetId}/quizset/${quizSetId}`, requestData).catch(() => {});
}, },
[quizSetId, roomId] [quizSetId, reportSetId]
); );
const QuizComponents = [ const QuizComponents = [
...quizList.map((quiz, index) => ( ...quizList.map((quiz, index) => (
<Quiz <Quiz
key={index} key={index}
step={index}
answers={answers.current} answers={answers.current}
setAnswers={(value) => { setAnswers={(value) => {
answers.current = answers.current.map((v, i) => (i === index ? value : v)); answers.current = answers.current.map((v, i) => (i === index ? value : v));
@ -37,7 +38,10 @@ export default function QuizSet({ quizSetId, finish }) {
key={Infinity} key={Infinity}
className={styles.message} className={styles.message}
> >
퀴즈 종료 <div>
<div>퀴즈 종료!</div>
<div className={styles.subMsg}>답안을 전송하고 있어요</div>
</div>
</div>, </div>,
]; ];
@ -60,7 +64,7 @@ export default function QuizSet({ quizSetId, finish }) {
return prev + 1; return prev + 1;
}); });
}, 5000); }, 10 * 1000);
return () => { return () => {
clearInterval(interval.current); clearInterval(interval.current);
@ -78,7 +82,9 @@ export default function QuizSet({ quizSetId, finish }) {
<> <>
{step === null ? ( {step === null ? (
<div className={styles.message}> <div className={styles.message}>
<span>퀴즈를 시작합니다</span> <span>
<Countdown seconds={10} /> 퀴즈를 시작합니다.
</span>
<LoadingIndicator /> <LoadingIndicator />
</div> </div>
) : ( ) : (

View File

@ -17,6 +17,7 @@
gap: 32px; gap: 32px;
width: 100%; width: 100%;
height: 100%; height: 100%;
text-align: center;
font-size: 24px; font-size: 24px;
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
@ -40,5 +41,24 @@
overflow: hidden; overflow: hidden;
margin-top: 16px; margin-top: 16px;
transform-origin: left center; transform-origin: left center;
animation: progress 5s linear infinite; animation: progress 10s linear infinite;
}
@keyframes shake {
0% {
rotate: -2.5deg;
}
100% {
rotate: 2.5deg;
}
}
.subMsg {
margin-top: 8px;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 1.5;
color: #ccc;
animation: shake 1s ease-in-out alternate-reverse infinite;
} }

View File

@ -4,7 +4,7 @@ export default function SideItem({ name, sub }) {
return ( return (
<li className={styles.item}> <li className={styles.item}>
<div>{name}</div> <div>{name}</div>
<div className={styles.sub}>{sub}</div> {sub && <div className={styles.sub}>{sub}</div>}
</li> </li>
); );
} }

View File

@ -6,15 +6,13 @@ export function useAuth() {
const setToken = useBoundStore((state) => state.setToken); const setToken = useBoundStore((state) => state.setToken);
const setUserType = useBoundStore((state) => state.setUserType); const setUserType = useBoundStore((state) => state.setUserType);
const login = (userId, password, onError = () => {}) => { const login = (userId, password) => {
const formData = { const formData = {
userId, userId,
password, password,
}; };
return instance return instance.post(`${API_URL}/user/login`, formData).then(({ data, config }) => {
.post(`${API_URL}/user/login`, formData)
.then(({ data, config }) => {
const { role: role, 'access-token': accessToken } = data; const { role: role, 'access-token': accessToken } = data;
config.headers.Authorization = `${accessToken}`; config.headers.Authorization = `${accessToken}`;
setToken(accessToken); setToken(accessToken);
@ -24,10 +22,7 @@ export function useAuth() {
} else if (role === 'STUDENT') { } else if (role === 'STUDENT') {
setUserType('student'); setUserType('student');
} }
}) return accessToken;
.catch((e) => {
alert('아이디 또는 비밀번호를 다시 확인해주세요.');
onError(e);
}); });
}; };

View File

@ -4,7 +4,7 @@ import { API_URL } from '../../constants';
export function useLectureEnroll(lectureId) { export function useLectureEnroll(lectureId) {
return useSuspenseQuery({ return useSuspenseQuery({
queryKey: ['lecturelist', lectureId], queryKey: ['enroll', lectureId],
queryFn: () => instance.get(`${API_URL}/registration/${lectureId}`), queryFn: () => instance.get(`${API_URL}/registration/${lectureId}`),
}); });
} }

View File

@ -4,7 +4,7 @@ import { API_URL } from '../../constants';
export function useLectureInfo(lectureId) { export function useLectureInfo(lectureId) {
return useSuspenseQuery({ return useSuspenseQuery({
queryKey: ['lecturelist', lectureId], queryKey: ['lectureInfo', lectureId],
queryFn: () => instance.get(`${API_URL}/lecture/${lectureId}`), queryFn: () => instance.get(`${API_URL}/lecture/${lectureId}`),
}); });
} }

View File

@ -3,8 +3,6 @@ import { chatClient } from '../../utils/chat/chatClient';
import useBoundStore from '../../store'; import useBoundStore from '../../store';
import { useQuizsets } from '../api/useQuizsets'; import { useQuizsets } from '../api/useQuizsets';
const USER_ID = crypto.getRandomValues(new Uint32Array(1))[0];
export default function useChatRoom(roomId) { export default function useChatRoom(roomId) {
const client = chatClient; const client = chatClient;
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
@ -13,13 +11,12 @@ export default function useChatRoom(roomId) {
const chatListRef = useRef(null); const chatListRef = useRef(null);
const { data: quizSetData } = useQuizsets(); const { data: quizSetData } = useQuizsets();
const quizSets = quizSetData?.data ?? []; const quizSets = quizSetData?.data ?? [];
const [quizSetId, setQuizSetId] = useState(null); const [quizSetInfo, setQuizSetInfo] = useState(null);
const startQuiz = (quizSetId) => { const startQuiz = (quizSetId) => {
chatClient.publish({ chatClient.publish({
destination: `/pub/chat.quiz.${roomId}`, destination: `/pub/chat.quiz.${roomId}`,
body: JSON.stringify({ body: JSON.stringify({
userId: USER_ID,
quizSetId, quizSetId,
}), }),
}); });
@ -36,7 +33,6 @@ export default function useChatRoom(roomId) {
chatClient.publish({ chatClient.publish({
destination: `/pub/chat.message.${roomId}`, destination: `/pub/chat.message.${roomId}`,
body: JSON.stringify({ body: JSON.stringify({
userId: USER_ID,
name: userName, name: userName,
content: text, content: text,
}), }),
@ -48,13 +44,13 @@ 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, quizSetId } = data; const { content: message, name, quizSetId, reportSetId } = data;
if (quizSetId !== undefined) { if (quizSetId !== undefined) {
setQuizSetId(quizSetId); setQuizSetInfo([quizSetId, reportSetId]);
return; return;
} }
setMessages((prev) => [...prev, { id: prev.length, text: message, isMine: USER_ID === data.userId, name }]); setMessages((prev) => [...prev, { id: prev.length, text: message, name }]);
}); });
}; };
client.activate(); client.activate();
@ -77,7 +73,7 @@ export default function useChatRoom(roomId) {
chatListRef, chatListRef,
startQuiz, startQuiz,
quizSets, quizSets,
quizSetId, quizSetInfo,
setQuizSetId, setQuizSetInfo,
}; };
} }

View File

@ -7,10 +7,9 @@ import { useQnas } from '../../hooks/api/useQnas';
export default function LearningLectureDetailPage() { export default function LearningLectureDetailPage() {
const { lectureId } = useParams(); const { lectureId } = useParams();
const { data: noticesData } = useNotices(lectureId); const { data: noticesData } = useNotices(lectureId);
const notices = noticesData?.data; const notices = noticesData?.data.slice(0, 3);
const { data: qnasData } = useQnas(lectureId); const { data: qnasData } = useQnas(lectureId);
const questions = qnasData?.data; const questions = qnasData?.data.slice(0, 3);
// TODO: QnA 3 slice
return ( return (
<section className={styles.previews}> <section className={styles.previews}>
@ -24,7 +23,6 @@ export default function LearningLectureDetailPage() {
title="Q&A" title="Q&A"
contents={questions} contents={questions}
/> />
<ArticlePreview title="커리큘럼" />
</section> </section>
); );
} }

View File

@ -5,12 +5,14 @@ import { useMyLectures } from '../../hooks/api/useMyLectures';
export default function LearningLecturesPage() { export default function LearningLecturesPage() {
const { data } = useMyLectures(); const { data } = useMyLectures();
const onGoingClasses = data?.data ?? []; const onGoingClasses = data?.data ?? [];
const hasOnGoingClasses = onGoingClasses.length > 0;
return ( return (
<section> <section>
<h2 className={styles.title}>수강중인 강의</h2> <h2 className={styles.title}>수강중인 강의</h2>
<div className={styles.grid}> <div className={styles.grid}>
{onGoingClasses.map?.((lecture) => ( {hasOnGoingClasses ? (
onGoingClasses.map?.((lecture) => (
<Link <Link
key={lecture.id} key={lecture.id}
to={`/lecture/${lecture.id}`} to={`/lecture/${lecture.id}`}
@ -19,7 +21,10 @@ export default function LearningLecturesPage() {
<div className={styles.thumbnail} /> <div className={styles.thumbnail} />
<div>{lecture.title}</div> <div>{lecture.title}</div>
</Link> </Link>
))} ))
) : (
<div className={styles.empty}>수강중인 강의가 없습니다.</div>
)}
</div> </div>
</section> </section>
); );

View File

@ -24,7 +24,8 @@ export default function QuestionListPage() {
title="수강신청관리" title="수강신청관리"
canCreate={false} canCreate={false}
> >
{lectures.map?.((lecture) => ( {lectures.length &&
lectures.map?.((lecture) => (
<LectureEnroll <LectureEnroll
key={`${lecture.id}`} key={`${lecture.id}`}
enrollid={lecture.id} enrollid={lecture.id}

View File

@ -1,6 +1,6 @@
import { LiveRoom } from '../../components/LiveRoom'; import { LiveRoom } from '../../components/LiveRoom';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useCallback, useEffect, useState } from 'react'; import { Suspense, useCallback, useEffect, useState } from 'react';
import { LiveKitRoom } from '@livekit/components-react'; import { LiveKitRoom } from '@livekit/components-react';
import instance from '../../utils/axios/instance'; import instance from '../../utils/axios/instance';
import { API_URL, ROOM_URL } from '../../constants'; import { API_URL, ROOM_URL } from '../../constants';
@ -43,7 +43,9 @@ export default function LivePage() {
}, 500); }, 500);
}} }}
> >
<Suspense fallback={<LoadingIndicator fill />}>
<LiveRoom /> <LiveRoom />
</Suspense>
</LiveKitRoom> </LiveKitRoom>
) : ( ) : (
<LoadingIndicator fill /> <LoadingIndicator fill />

View File

@ -22,8 +22,14 @@ export default function LoginPage() {
const id = idRef.current.value; const id = idRef.current.value;
const password = passwordRef.current.value; const password = passwordRef.current.value;
login(id, password).then(() => { login(id, password)
.then(() => {
navigate('/', { replace: true }); navigate('/', { replace: true });
})
.catch(() => {
alert('아이디 또는 비밀번호를 다시 확인해주세요.');
passwordRef.current.value = '';
idRef.current.focus();
}); });
}; };

View File

@ -15,7 +15,8 @@ export default function NoticeListPage() {
title="공지사항" title="공지사항"
canCreate={userType === 'teacher'} canCreate={userType === 'teacher'}
> >
{notices.map?.((notice) => ( {notices.length &&
notices.map?.((notice) => (
<ArticleLink <ArticleLink
key={`${notice.id}`} key={`${notice.id}`}
title={notice.title} title={notice.title}

View File

@ -15,7 +15,8 @@ export default function QuestionListPage() {
title="Q&A" title="Q&A"
canCreate={userType === 'student'} canCreate={userType === 'student'}
> >
{questions.map?.((question) => ( {questions.length &&
questions.map?.((question) => (
<ArticleLink <ArticleLink
key={`${question.title}${question.createtAt}`} key={`${question.title}${question.createtAt}`}
title={question.title} title={question.title}

View File

@ -15,7 +15,8 @@ export default function QuizsetListPage() {
title="퀴즈 목록" title="퀴즈 목록"
canCreate={true} canCreate={true}
> >
{quizsets.map?.((quizset) => ( {quizsets.length &&
quizsets.map?.((quizset) => (
<ArticleLink <ArticleLink
key={`${quizset.quizSetId}`} key={`${quizset.quizSetId}`}
title={quizset.title} title={quizset.title}

View File

@ -1,3 +1,4 @@
import styles from './StudentHomePage.module.css';
import { ClassCard } from '../../components/ClassCard'; import { ClassCard } from '../../components/ClassCard';
import { ClassGrid } from '../../components/ClassGrid'; import { ClassGrid } from '../../components/ClassGrid';
import { MaxWidthLayout } from '../../components/Layout'; import { MaxWidthLayout } from '../../components/Layout';
@ -7,6 +8,7 @@ import { useMyLectures } from '../../hooks/api/useMyLectures';
export default function StudentHomePage() { export default function StudentHomePage() {
const { data: myLectures } = useMyLectures(); const { data: myLectures } = useMyLectures();
const onGoingClasses = myLectures?.data ?? []; const onGoingClasses = myLectures?.data ?? [];
const hasOnGoingClasses = onGoingClasses.length > 0;
const { data: allLectures } = useLectures(); const { data: allLectures } = useLectures();
const allClasses = allLectures?.data ?? []; const allClasses = allLectures?.data ?? [];
@ -14,7 +16,8 @@ export default function StudentHomePage() {
return ( return (
<MaxWidthLayout> <MaxWidthLayout>
<ClassGrid title="수강중인 강의"> <ClassGrid title="수강중인 강의">
{onGoingClasses.map?.((lecture) => ( {hasOnGoingClasses ? (
onGoingClasses.map?.((lecture) => (
<ClassCard <ClassCard
key={lecture.id} key={lecture.id}
path={`/lecture/${lecture.id}`} path={`/lecture/${lecture.id}`}
@ -22,7 +25,10 @@ export default function StudentHomePage() {
> >
{lecture.title} {lecture.title}
</ClassCard> </ClassCard>
))} ))
) : (
<div className={styles.msg}>수강중인 강의가 없어요.</div>
)}
</ClassGrid> </ClassGrid>
<ClassGrid title="전체 강의"> <ClassGrid title="전체 강의">
{allClasses.map?.((lecture) => ( {allClasses.map?.((lecture) => (

View File

@ -0,0 +1,10 @@
.msg {
grid-column: 1 / -1;
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
color: var(--text-color-tertiary);
font-size: 20px;
line-height: 1.2;
}

View File

@ -14,7 +14,8 @@ export default function StudentListPage() {
return ( return (
<ArticleBoard title="수강생 관리"> <ArticleBoard title="수강생 관리">
{students.map?.((student) => { {students.length &&
students.map?.((student) => {
return ( return (
<ArticleLink <ArticleLink
key={`${student.name}${student.sub}`} key={`${student.name}${student.sub}`}

View File

@ -5,6 +5,8 @@
align-items: center; align-items: center;
gap: 20px; gap: 20px;
margin-bottom: 32px; margin-bottom: 32px;
width: 295px;
height: 220px;
background-color: var(--background); background-color: var(--background);
color: var(--text-color); color: var(--text-color);
stroke: var(--text-color); stroke: var(--text-color);
@ -19,3 +21,14 @@
background-color: var(--background-secondary); background-color: var(--background-secondary);
} }
} }
.msg {
grid-column: 1 / -1;
display: flex;
justify-content: center;
align-items: center;
padding: 40px 0;
color: var(--text-color-tertiary);
font-size: 20px;
line-height: 1.2;
}

View File

@ -1,30 +0,0 @@
import ArticlePreview from '../../components/Article/ArticlePreview/ArticlePreview';
import styles from './TeacherLectureDetailPage.module.css';
import { useNotices } from '../../hooks/api/useNotices';
import { useParams } from 'react-router-dom';
import { useQnas } from '../../hooks/api/useQnas';
export default function TeacherLectureDetailPage() {
const { lectureId } = useParams();
const { data: noticesData } = useNotices(lectureId);
const notices = noticesData?.data;
const { data: qnasData } = useQnas(lectureId);
const questions = qnasData?.data;
return (
<main className={styles.previews}>
{/* FIXME: 밑에 ArticlePreview 바꿔야함. 공지사항 Q&A 커리큘럼 으로 나눠서 작성할 수 있게 바꾸고 링크 상위 3개만 받고 링크 줄 수 있게 할지 말지. 이거 바꾸면 LearningLectureDetailPage도 똑같이 바꾸면 될듯*/}
<ArticlePreview
to="notice"
title="공지사항"
contents={notices}
/>
<ArticlePreview
to="qna"
title="Q&A"
contents={questions}
/>
<ArticlePreview title="커리큘럼" />
</main>
);
}

View File

@ -1,5 +0,0 @@
.previews {
display: flex;
flex-direction: column;
gap: 40px;
}

View File

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

View File

@ -16,7 +16,8 @@ export default function TeacherNoticeListPage() {
title="공지사항" title="공지사항"
canCreate={true} canCreate={true}
> >
{notices.map?.((notice) => { {notices.length &&
notices.map?.((notice) => {
if (notice.sub && notice.title) { if (notice.sub && notice.title) {
return ( return (
<ArticleLink <ArticleLink