Merge branch 'frontend' into 'fe/useQnaEdit'

# Conflicts:
#   frontend/src/Router.jsx
This commit is contained in:
조민우 2024-08-02 16:43:41 +09:00
commit 777495ae4b
55 changed files with 1223 additions and 164 deletions

View File

@ -11,6 +11,7 @@
"@stomp/stompjs": "^7.0.0",
"@tanstack/react-query": "^5.49.2",
"axios": "^1.7.2",
"livekit-client": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1",
@ -447,6 +448,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz",
"integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@ -1002,6 +1009,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@livekit/protocol": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.19.1.tgz",
"integrity": "sha512-PQYIuqRv++fRik9tKulJ0C0tT5O4cNviBA7OxwLTCBFDxJpve8ua8/JZ+nK+7r4j2KbLfVjsJYop9wcTCgRn7Q==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.7.2"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3177,6 +3193,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
@ -4250,6 +4275,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/livekit-client": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.4.1.tgz",
"integrity": "sha512-GZwYsrrv6QKyfM3TCxgFoNQkSiFZV9cJbiW2X2N4fSt7KxeHwy9/tVGUr79ak6SIa0uIa8RuG1ELHzsrutsCow==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/protocol": "1.19.1",
"events": "^3.3.0",
"loglevel": "^1.8.0",
"sdp-transform": "^2.14.1",
"ts-debounce": "^4.0.0",
"tslib": "2.6.3",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.0"
}
},
"node_modules/local-pkg": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz",
@ -4290,6 +4331,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/loglevel": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
"integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -5185,6 +5239,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.1",
"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"
}
},
"node_modules/safe-array-concat": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz",
@ -5231,6 +5295,21 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/sdp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz",
"integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.2.tgz",
"integrity": "sha512-icY6jVao7MfKCieyo1AyxFYm1baiM+fA00qW/KrNNVlkxHAd34riEKuEkUe4bBb3gJwLJZM+xT60Yj1QL8rHiA==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -5587,11 +5666,16 @@
"node": ">=4"
}
},
"node_modules/ts-debounce": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
@ -5707,6 +5791,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/ufo": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz",
@ -5940,6 +6033,19 @@
}
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.1.tgz",
"integrity": "sha512-1AQO+d4ElfVSXyzNVTOewgGT/tAomwwztX/6e3totvyyzXPvXIIuUUjAmyZGbKBKbZOXauuJooZm3g6IuFuiNQ==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -16,6 +16,7 @@
"@stomp/stompjs": "^7.0.0",
"@tanstack/react-query": "^5.49.2",
"axios": "^1.7.2",
"livekit-client": "^2.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1",

View File

@ -5,8 +5,8 @@ import HomePage from './pages/HomePage';
import NotFoundPage from './pages/NotFoundPage';
import { lazy } from 'react';
import MyPageLayout from './components/Layout/MyPageLayout';
import { LiveLayout } from './components/Layout';
const LiveLayout = lazy(async () => await import('./components/Layout/LiveLayout'));
const LivePage = lazy(async () => await import('./pages/LivePage'));
const LectureLayout = lazy(async () => await import('./components/Layout/LectureLayout'));
const LearningLectureDetailPage = lazy(async () => await import('./pages/LearningLectureDetailPage'));
@ -26,6 +26,10 @@ const PasswordChangePage = lazy(async () => await import('./pages/PasswordChange
const LearningLecturesPage = lazy(async () => await import('./pages/LearningLecturesPage'));
const LectureCreatePage = lazy(async () => await import('./pages/LectureCreatePage'));
const EditQuestionPage = lazy(async () => await import('./pages/EditQuestionPage'));
const LectureEditPage = lazy(async () => await import('./pages/LectureEditPage'));
const QuizsetListPage = lazy(async () => await import('./pages/QuizsetListPage'));
const QuizsetWritePage = lazy(async () => await import('./pages/QuizsetWritePage'));
const QuizsetDetailPage = lazy(async () => await import('./pages/QuizsetDetailPage'));
const router = createBrowserRouter([
{
@ -55,6 +59,10 @@ const router = createBrowserRouter([
path: 'lecture/:lectureId/info',
element: <LectureInfoPage />,
},
{
path: 'lecture/:lectureId/edit',
element: <LectureEditPage />,
},
{
path: 'lecture/:lectureId',
element: <LectureLayout />,
@ -63,6 +71,10 @@ const router = createBrowserRouter([
index: true,
element: <LearningLectureDetailPage />,
},
{
path: 'edit',
element: <LectureEditPage />,
},
{
path: 'notice',
children: [
@ -119,6 +131,23 @@ const router = createBrowserRouter([
},
],
},
{
path: 'quiz',
children: [
{
index: true,
element: <QuizsetListPage />,
},
{
path: 'write',
element: <QuizsetWritePage />,
},
{
path: ':quizsetId',
element: <QuizsetDetailPage />,
},
],
},
],
},
{

View File

@ -1,7 +1,16 @@
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import styles from './Header.module.css';
import useBoundStore from '../../store';
import { useAuth } from '../../hooks/api/useAuth';
export default function Header() {
const navigate = useNavigate();
const userType = useBoundStore((state) => state.userType);
const { logout } = useAuth();
const handleClick = () => {
logout().then(navigate('/'));
};
return (
<header className={styles.header}>
<nav className={styles.nav}>
@ -24,16 +33,25 @@ export default function Header() {
<Link to={'/'}>수강중인 강의</Link>
</li>
<li>
<Link to={'/'}> 학습</Link>
<Link to={'/live/1'}>live</Link>
</li>
</ul>
<ul className={styles.group}>
<li>
<Link to={'user/my'}>마이페이지</Link>
</li>
<li>
<Link to={'/auth/login'}>로그인</Link>
</li>
{userType && (
<>
<li>
<Link to={'user/my'}>마이페이지</Link>
</li>
<li>
<Link onClick={handleClick}>로그아웃</Link>
</li>
</>
)}
{!userType && (
<li>
<Link to={'/auth/login'}>로그인</Link>
</li>
)}
</ul>
</nav>
</header>

View File

@ -5,14 +5,31 @@ import MaxWidthLayout from './MaxWidthLayout';
import { Suspense } from 'react';
import useBoundStore from '../../store';
import { useLectureInfo } from '../../hooks/api/useLectureInfo';
import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator';
import { useLectureDelete } from '../../hooks/api/useLectureDelete';
import { useNavigate } from 'react-router-dom';
export default function LectureLayout() {
const { lectureId } = useParams();
const navigate = useNavigate();
const { lectureDelete } = useLectureDelete();
const { data } = useLectureInfo(lectureId);
const lecture = data?.data;
console.log(lecture);
const userType = useBoundStore((state) => state.userType);
const handleDelete = () => {
lectureDelete(lectureId);
navigate('..');
};
const lectureData = {
title: lecture.title,
description: lecture.description,
plan: lecture.plan,
startDate: lecture.startDate,
endDate: lecture.endDate,
time: lecture.time,
};
console.log(lectureData);
return (
<>
<LectureHeader
@ -42,6 +59,13 @@ export default function LectureLayout() {
name="수강생"
sub="총 12명"
/>
<SideLink
to={'edit'}
state={lectureData}
>
강의 정보 수정
</SideLink>
<button onClick={handleDelete}>강의 삭제</button>
</SideBar>
)}
{userType === 'student' && (
@ -58,7 +82,7 @@ export default function LectureLayout() {
)}
</aside>
<main>
<Suspense fallback={<div>loading</div>}>
<Suspense fallback={<LoadingIndicator fill />}>
<Outlet />
</Suspense>
</main>

View File

@ -1,13 +1,17 @@
import { Outlet } from 'react-router-dom';
import styles from './LiveLayout.module.css';
import { LiveHeader } from '../LiveHeader';
import { Suspense } from 'react';
import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator';
export default function LiveLayout() {
return (
<>
<LiveHeader />
<div className={styles.wrapper}>
<Outlet />
<Suspense fallback={<LoadingIndicator fill />}>
<Outlet />
</Suspense>
</div>
</>
);

View File

@ -4,7 +4,7 @@
align-items: stretch;
width: 100vw;
height: 100vh;
padding-top: 64px;
padding-top: 48px;
box-sizing: border-box;
& > main {

View File

@ -3,6 +3,7 @@ import MaxWidthLayout from '../../components/Layout/MaxWidthLayout';
import SideBar from '../../components/SideBar/SideBar';
import SideLink from '../../components/SideBar/SideLink';
import { Suspense } from 'react';
import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator';
export default function MyPageLayout() {
return (
@ -21,8 +22,7 @@ export default function MyPageLayout() {
</SideBar>
</aside>
<main>
{/* TODO: 로딩 컴포넌트 추가 */}
<Suspense fallback={<div>loading</div>}>
<Suspense fallback={<LoadingIndicator fill />}>
<Outlet />
</Suspense>
</main>

View File

@ -3,6 +3,7 @@ import { Footer } from '../Footer';
import { Header } from '../Header';
import styles from './PageLayout.module.css';
import { Suspense } from 'react';
import LoadingIndicator from '../LoadingIndicator.jsx/LoadingIndicator';
export default function PageLayout() {
return (
@ -10,7 +11,7 @@ export default function PageLayout() {
<Header />
<div className={styles.body}>
<div className={styles.contents}>
<Suspense fallback={<div>loading</div>}>
<Suspense fallback={<LoadingIndicator fill />}>
<Outlet />
</Suspense>
</div>

View File

@ -0,0 +1,131 @@
import { useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import styles from './LectureForm.module.css';
import EditIcon from '/src/assets/icons/edit.svg?react';
import BackIcon from '/src/assets/icons/back.svg?react';
export default function LectureForm({ title, topic, to, initialValues = {}, onSubmit, onCreate = false }) {
// TODO: , useState
const titleRef = useRef('');
const descriptionRef = useRef('');
const planRef = useRef('');
const startDateRef = useRef('');
const endDateRef = useRef('');
const timeRef = useRef('');
const imageFileRef = useRef(null);
//
useEffect(() => {
if (initialValues.title) titleRef.current.value = initialValues.title;
if (initialValues.description) descriptionRef.current.value = initialValues.description;
if (initialValues.plan) planRef.current.value = initialValues.plan;
if (initialValues.startDate)
startDateRef.current.value = new Date(initialValues.startDate).toISOString().split('T')[0];
if (initialValues.endDate) endDateRef.current.value = new Date(initialValues.endDate).toISOString().split('T')[0];
if (initialValues.time) timeRef.current.value = initialValues.time;
}, [initialValues]);
const handleSubmit = async (e) => {
e.preventDefault();
const lectureObject = {
title: titleRef.current.value,
description: descriptionRef.current.value,
plan: planRef.current.value,
startDate: new Date(startDateRef.current.value).toISOString(),
endDate: new Date(endDateRef.current.value).toISOString(),
time: timeRef.current.value,
};
const formData = new FormData();
formData.append('lectureCreateRequest', new Blob([JSON.stringify(lectureObject)], { type: 'application/json' }));
const imageFile = (imageFileRef.current && imageFileRef.current.files[0]) ?? null;
if (imageFile) {
formData.append('image', imageFile);
}
onSubmit(lectureObject, imageFile);
};
return (
<div className={styles.createClass}>
<header className={styles.header}>
<Link
to={to}
className={styles.goBack}
>
<BackIcon />
<span>{title}</span>
</Link>
<div className={styles.title}>{topic}</div>
</header>
<form onSubmit={handleSubmit}>
<div className={styles.inputField}>
<label className={styles.label}>강의명</label>
<input
className={styles.input}
ref={titleRef}
type="text"
placeholder="강의명을 입력하세요"
/>
</div>
<div className={styles.inputField}>
<label className={styles.label}>설명</label>
<textarea
ref={descriptionRef}
className={styles.textarea}
placeholder="강의에 대한 설명을 입력하세요"
></textarea>
</div>
<div className={styles.inputField}>
<label className={styles.label}>강의 계획</label>
<textarea
ref={planRef}
className={styles.textarea}
placeholder="강의 계획을 입력하세요"
></textarea>
</div>
<div className={styles.inputField}>
<label className={styles.label}>강의 기간</label>
<input
className={styles.input}
ref={startDateRef}
type="date"
/>
<input
className={styles.input}
ref={endDateRef}
type="date"
/>
</div>
<div className={styles.inputField}>
<label className={styles.label}>수업 시간</label>
<input
type="text"
ref={timeRef}
className={styles.input}
placeholder="실제 강의 진행 시간을 입력하세요"
/>
</div>
{onCreate && (
<div className={styles.inputField}>
<label className={styles.label}>수업 이미지</label>
<input
type="file"
ref={imageFileRef}
accept=".png, .jpg, .jpeg"
/>
</div>
)}
<button
type="submit"
className={styles.button}
>
<EditIcon />
<div>제출</div>
</button>
</form>
</div>
);
}

View File

@ -8,6 +8,30 @@
gap: 40px;
}
.header {
display: flex;
flex-direction: column;
align-items: start;
gap: 8px;
}
.goBack {
display: flex;
align-items: center;
gap: 4px;
font-size: 20px;
line-height: 1.2;
font-weight: 400;
color: var(--text-color-secondary);
stroke: var(--text-color-secondary);
}
.title {
font-size: 32px;
line-height: 1.2;
font-weight: 800;
}
.inputField {
display: flex;
flex-direction: column;
@ -52,18 +76,17 @@
.button {
display: flex;
flex-direction: row;
padding: 16px 24px;
border-radius: 8px;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--primary-color);
background-color: var(--primary-color);
color: var(--on-primary);
gap: 8px;
align-self: end;
}
.buttonText {
stroke: var(--on-primary);
font-size: 16px;
line-height: 1.4;
font-weight: 700;
align-self: end;
border-radius: 8px;
cursor: pointer;
}

View File

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

View File

@ -0,0 +1,22 @@
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

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

View File

@ -3,7 +3,7 @@
top: 0;
left: 0;
width: 100%;
height: 64px;
height: 48px;
background-color: var(--background);
border-bottom: 1px solid var(--border-color);
z-index: 9999;
@ -27,7 +27,7 @@
& > h1,
& > h2 {
font-size: 16px;
font-size: 14px;
line-height: 1.4;
font-weight: 700;
color: var(--text-color);
@ -35,6 +35,7 @@
}
& > div {
font-size: 14px;
font-weight: 400;
color: var(--text-color-secondary);
}

View File

@ -0,0 +1,46 @@
import styles from './LiveRoom.module.css';
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}
/>
)
)}
</div>
{mainTrack && <LiveVideo track={mainTrack} />}
</>
) : (
<LoadingIndicator fill />
)}
</main>
<aside>
<ChatRoom />
<button onClick={leaveRoom}>나가기</button>
</aside>
</>
);
}

View File

@ -0,0 +1,27 @@
.videoWrapper {
flex-grow: 0;
display: flex;
overflow-x: auto;
height: 80px;
gap: 10px;
padding: 10px;
border-bottom: 1px solid var(--border-color);
& > audio {
display: none;
}
&::-webkit-scrollbar {
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: var(--text-color-tertiary);
border-radius: 6px;
}
}
.myVideo {
border-radius: 12px;
border: 2px solid var(--border-color);
}

View File

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

View File

@ -0,0 +1,24 @@
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

@ -0,0 +1,36 @@
.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

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

View File

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

View File

@ -0,0 +1,24 @@
@keyframes spin {
to {
transform: rotate(720deg);
}
}
.wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
margin: 0 auto;
}
.indicator {
width: 64px;
height: 64px;
border: 5px solid var(--background-secondary);
border-top-color: var(--primary-color);
border-radius: 50%;
box-sizing: border-box;
animation: spin 2s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
}

View File

@ -0,0 +1,86 @@
import { useState } from 'react';
import styles from './QuizCard.module.css';
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 handleChoiceChange = (num, content) => {
const updatedChoices = choices.map((choice) => (choice.num === num ? { ...choice, content } : choice));
setChoices(updatedChoices);
updateQuiz(index, { question, answer, choices: updatedChoices });
};
const handleAddChoice = () => {
if (choices.length < 4) {
const newChoice = { num: choices.length + 1, content: '' };
const updatedChoices = [...choices, newChoice];
setChoices(updatedChoices);
updateQuiz(index, { question, answer, choices: updatedChoices });
}
};
const handlePopChoice = () => {
if (choices.length > 0) {
const updatedChoices = choices.slice(0, -1);
setChoices(updatedChoices);
updateQuiz(index, { question, answer, choices: updatedChoices });
}
};
return (
<div className={styles.card}>
<label>질문</label>
<input
type="text"
value={question}
onChange={(e) => {
setQuestion(e.target.value);
updateQuiz(index, { question: e.target.value, answer, choices });
}}
placeholder="질문 내용을 입력하세요"
/>
<label>정답</label>
<input
type="text"
value={answer}
onChange={(e) => {
setAnswer(e.target.value);
updateQuiz(index, { question, answer: e.target.value, choices });
}}
placeholder="정답을 입력하세요"
/>
<div>
<span>Tip: 선택지를 넣지 않는다면 단답형 문제가 됩니다</span>
</div>
<div className={styles.buttonsWrapper}>
<button
type="button"
onClick={handleAddChoice}
className={styles.button}
>
선택지 추가하기
</button>
<button
type="button"
onClick={handlePopChoice}
className={styles.removeButton}
>
선택지 줄이기
</button>
</div>
{choices.map?.((choice, idx) => (
<div key={idx}>
<label>선택지 {choice.num} : </label>
<input
type="text"
value={choice.content}
onChange={(e) => handleChoiceChange(choice.num, e.target.value)}
placeholder={`Choice ${choice.num}`}
/>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,47 @@
.card {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 20px;
width: 400px;
display: flex;
flex-direction: column;
gap: 8px;
}
.buttonsWrapper {
display: flex;
flex-direction: row;
gap: 8px;
}
.removeButton {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid var(--accent-color);
background-color: var(--accent-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;
}
.button {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid var(--primary-color);
background-color: var(--primary-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

@ -0,0 +1,81 @@
import { useState } from 'react';
import QuizCard from './QuizCard';
import styles from './QuizsetForm.module.css';
import EditIcon from '/src/assets/icons/edit.svg?react';
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 [imageFile, setImageFile] = useState(null);
const handleAddQuiz = () => {
setQuizzes([...quizzes, { question: '', answer: '', choices: [] }]);
};
const updateQuiz = (index, updatedQuiz) => {
const updatedQuizzes = quizzes.map((quiz, i) => (i === index ? updatedQuiz : quiz));
setQuizzes(updatedQuizzes);
};
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}
>
<BackIcon />
<span>{headerTitle}</span>
</Link>
<div className={styles.title}>{topic}</div>
</header>
<form
className={styles.form}
onSubmit={(e) => onSubmit(e, title, quizzes, imageFile)}
>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="퀴즈셋 제목을 입력해주세요"
/>
{quizzes.map((quiz, index) => (
<QuizCard
key={index}
quiz={quiz}
index={index}
updateQuiz={updateQuiz}
/>
))}
<button
type="button"
onClick={handleAddQuiz}
className={styles.button}
>
퀴즈 추가하기
</button>
<label>퀴즈 이미지</label>
<input
type="file"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
/>
<button
type="submit"
className={styles.button}
>
<EditIcon />
<div>제출</div>
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,74 @@
.quizsetForm {
background: var(--background-color);
width: 100%;
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 40px;
}
.header {
display: flex;
flex-direction: column;
align-items: start;
gap: 8px;
}
.goBack {
display: flex;
align-items: center;
gap: 4px;
font-size: 20px;
line-height: 1.2;
font-weight: 400;
color: var(--text-color-secondary);
stroke: var(--text-color-secondary);
}
.title {
font-size: 32px;
line-height: 1.2;
font-weight: 800;
}
.form {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 20px;
}
.removeButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--accent-color);
background-color: var(--accent-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;
}
.button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--primary-color);
background-color: var(--primary-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

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

View File

@ -0,0 +1,22 @@
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 }) {
return (
<div className={styles.quizsetDetail}>
<header className={styles.header}>
<Link
to={'..'}
className={styles.goBack}
>
<BackIcon />
<span>{topic}</span>
</Link>
<div>
<h1 className={styles.title}>{title}</h1>
</div>
</header>
</div>
);
}

View File

@ -0,0 +1,36 @@
.quizsetDetail {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
background-color: var(--background-default);
color: var(--text-color);
box-sizing: border-box;
margin: 0;
padding: 0;
}
.header {
display: flex;
flex-direction: column;
align-items: start;
gap: 8px;
}
.goBack {
display: flex;
align-items: center;
gap: 4px;
font-size: 20px;
line-height: 1.2;
font-weight: 400;
color: var(--text-color-secondary);
stroke: var(--text-color-secondary);
}
.title {
font-size: 32px;
line-height: 1.2;
font-weight: 800;
margin: 0;
}

View File

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

View File

@ -1,11 +1,12 @@
import { NavLink } from 'react-router-dom';
import styles from './SideLink.module.css';
export default function SideLink({ children, to, end = false }) {
export default function SideLink({ children, to, state = null, end = false }) {
return (
<li className={styles.list}>
<NavLink
to={to}
state={{ from: state }}
className={({ isActive }) => (isActive ? styles.active : styles.link)}
end={end}
>

View File

@ -44,5 +44,16 @@ export function useAuth() {
});
};
return { login, userRegister };
const logout = () => {
return instance
.post(`${API_URL}/user/logout`)
.then((response) => {
console.log(response);
setUserType(null);
setToken(null);
})
.catch((e) => console.log(e));
};
return { login, logout, userRegister };
}

View File

@ -0,0 +1,10 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useLectureDelete() {
const lectureDelete = (lectureId) => {
return instance.delete(`${API_URL}/lecture/${lectureId}`);
};
return { lectureDelete };
}

View File

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

View File

@ -0,0 +1,10 @@
import { useSuspenseQuery } from '@tanstack/react-query';
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQuizsetDetail(id) {
return useSuspenseQuery({
queryKey: ['quizset', id],
queryFn: () => instance.get(`${API_URL}/quiz/${id}`),
});
}

View File

@ -0,0 +1,14 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQuizsetWrite() {
const quizsetWrite = (formData) => {
return instance.post(`${API_URL}/quiz`, formData, {
headers: {
'Content-type': 'multipart/form-data',
},
});
};
return { quizsetWrite };
}

View File

@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQuizsets() {
return useQuery({
queryKey: ['quizsetList'],
queryFn: () => instance.get(`${API_URL}/quiz`),
});
}

View File

@ -20,7 +20,7 @@ export default function useChatRoom(roomId) {
}
chatClient.publish({
destination: `/pub/message/${roomId}`,
destination: `/pub/chat.message.${roomId}`,
body: JSON.stringify({
userId: USER_ID,
name: userName,
@ -32,7 +32,7 @@ export default function useChatRoom(roomId) {
useEffect(() => {
client.onConnect = () => {
client.subscribe(`/sub/channel/${roomId}`, (response) => {
client.subscribe(`/exchange/chat.exchange/*.room.${roomId}`, (response) => {
const data = JSON.parse(response.body);
const { content: message, name } = data;

View File

@ -0,0 +1,70 @@
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

@ -1,4 +1,3 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from 'react-router-dom';
@ -10,9 +9,7 @@ const queryClient = new QueryClient({
});
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);

View File

@ -1,36 +1,13 @@
import styles from './LectureCreatePage.module.css';
import { useRef } from 'react';
import { LectureForm } from '../../components/LectureForm';
import { useLectureCreate } from '../../hooks/api/useLectureCreate';
export default function LectureCreatePage() {
// TODO:
const titleRef = useRef('');
const descriptionRef = useRef('');
const planRef = useRef('');
const startDateRef = useRef('');
const endDateRef = useRef('');
const timeRef = useRef(null);
const imageFileRef = useRef('');
const { lectureCreate } = useLectureCreate();
const handleSubmit = async (e) => {
e.preventDefault();
const lectureObject = {
title: titleRef.current.value,
description: descriptionRef.current.value,
plan: planRef.current.value,
startDate: new Date(startDateRef.current.value).toISOString(),
endDate: new Date(endDateRef.current.value).toISOString(),
time: timeRef.current.value,
};
const handleSubmit = async (lectureObject, imageFile) => {
const formData = new FormData();
formData.append('lectureCreateRequest', new Blob([JSON.stringify(lectureObject)], { type: 'application/json' }));
const imageFile = imageFileRef.current.files[0] ?? null;
if (imageFile) {
formData.append('image', imageFile);
}
@ -40,72 +17,13 @@ export default function LectureCreatePage() {
};
return (
<form
className={styles.createClass}
onSubmit={handleSubmit}
>
<div className={styles.inputField}>
<label className={styles.label}>강의명</label>
<input
className={styles.input}
ref={titleRef}
type="text"
placeholder="강의명을 입력하세요"
/>
</div>
<div className={styles.inputField}>
<label className={styles.label}>설명</label>
<textarea
ref={descriptionRef}
className={styles.textarea}
placeholder="강의에 대한 설명을 입력하세요"
></textarea>
</div>
<div className={styles.inputField}>
<label className={styles.label}>강의 계획</label>
<textarea
ref={planRef}
className={styles.textarea}
placeholder="강의 계획을 입력하세요"
></textarea>
</div>
<div className={styles.inputField}>
<label className={styles.label}>강의 기간</label>
<input
className={styles.input}
ref={startDateRef}
type="date"
/>
<input
className={styles.input}
ref={endDateRef}
type="date"
/>
</div>
<div className={styles.inputField}>
<label className={styles.label}>수업 시간</label>
<input
type="text"
ref={timeRef}
className={styles.input}
placeholder="실제 강의 진행 시간을 입력하세요"
></input>
</div>
<div className={styles.inputField}>
<label className={styles.label}>수업 이미지</label>
<input
type="file"
ref={imageFileRef}
accept=""
/>
</div>
<button
type="submit"
className={styles.button}
>
<div></div>
<div className={styles.buttonText}>강의 생성</div>
</button>
</form>
<div>
<h1>강의 생성</h1>
<LectureForm
title={'강의 생성'}
onSubmit={handleSubmit}
onCreate={true}
/>
</div>
);
}

View File

@ -0,0 +1,34 @@
import { LectureForm } from '../../components/LectureForm';
import { useLectureEdit } from '../../hooks/api/useLectureEdit';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
export default function LecutreEditPage() {
const { lectureId } = useParams();
const location = useLocation();
const initialData = location.state.from;
const navigate = useNavigate();
const { lectureEdit } = useLectureEdit();
const handleSubmit = async (lectureObject) => {
const response = await lectureEdit(lectureId, lectureObject)
.then((res) => {
console.log(res);
navigate('..');
})
.catch((err) => console.log(err));
console.log(response?.data);
};
return (
<div>
<LectureForm
initialValues={initialData}
onSubmit={handleSubmit}
title={'강의 홈'}
topic={'강의 수정'}
to={'..'}
/>
</div>
);
}

View File

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

View File

@ -1,14 +1,25 @@
import useBoundStore from '../../store';
import ChatRoom from '../../components/ChatRoom/ChatRoom';
import { LiveRoom } from '../../components/LiveRoom';
import { useParams } from 'react-router-dom';
import useRoom from '../../hooks/live/useRoom';
import { useEffect } from 'react';
export default function LivePage() {
const liveTabStatus = useBoundStore((state) => state.liveTabStatus);
// const setLiveTabStatus = useBoundStore((state) => state.setLiveTabStatus);
const { roomId } = useParams();
const { room, joinRoom, localTrack, remoteTracks, mainTrack, leaveRoom } = useRoom(roomId);
useEffect(() => {
if (!room) {
joinRoom();
}
}, [joinRoom, room]);
return (
<>
<main></main>
<aside>{liveTabStatus === 'chat' ? <ChatRoom /> : <div>Quiz</div>}</aside>
</>
<LiveRoom
room={room}
localTrack={localTrack}
remoteTracks={remoteTracks}
leaveRoom={leaveRoom}
mainTrack={mainTrack}
/>
);
}

View File

@ -0,0 +1,11 @@
import { useQuizsetDetail } from '../../hooks/api/useQuizsetDetail';
import { useParams } from 'react-router-dom';
import { QuizsetDetail } from '../../components/QuizsetDetail';
export default function QuizsetListPage() {
const { lectureId } = useParams();
const { data } = useQuizsetDetail(lectureId);
const quizset = data?.data ?? [];
console.log(quizset);
return <QuizsetDetail title={quizset.title} />;
}

View File

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

View File

@ -0,0 +1,27 @@
import { ArticleLink } from '../../components/ArticleLink';
import ArticleBoard from '../../components/ArticleBoard/ArticleBoard';
import { useQuizsets } from '../../hooks/api/useQuizsets';
import { useParams } from 'react-router-dom';
// import useBoundStore from '../../store';
export default function QuizsetListPage() {
const { lectureId } = useParams();
const { data } = useQuizsets(lectureId);
const quizsets = data?.data ?? [];
// const userType = useBoundStore((state) => state.userType);
console.log(quizsets);
return (
<ArticleBoard
title="퀴즈 목록"
canCreate={true}
>
{quizsets.map?.((quizset) => (
<ArticleLink
key={`${quizset.quizSetId}`}
title={quizset.title}
to={`${quizset.quizSetId}`}
/>
))}
</ArticleBoard>
);
}

View File

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

View File

@ -0,0 +1,32 @@
import { QuizsetForm } from '../../components/QuizForm';
import { useQuizsetWrite } from '../../hooks/api/useQuizsetWrite';
export default function QuizsetWritePage() {
// TODO: lecture
const { quizsetWrite } = useQuizsetWrite();
const handleSubmit = async (e, title, quizzes, imageFile = null) => {
e.preventDefault();
const quizsetObject = {
title,
quizzes,
};
console.log(quizsetObject);
console.log(imageFile);
const formData = new FormData();
formData.append('quizSetCreateRequest', new Blob([JSON.stringify(quizsetObject)], { type: 'application/json' }));
if (imageFile) {
formData.append('image', imageFile);
}
const response = await quizsetWrite(formData);
console.log(response);
};
return (
<QuizsetForm
onSubmit={handleSubmit}
headerTitle={'퀴즈 목록'}
topic={'퀴즈 작성'}
to={'..'}
/>
);
}

View File

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

View File

@ -2,14 +2,17 @@ import { create } from 'zustand';
import { userTypeSlice } from './userTypeSlice';
import { tokenSlice } from './tokenSlice';
import { userNameSlice } from './userNameSlice';
import { liveSlice } from './liveSlice';
import { persist } from 'zustand/middleware';
const useBoundStore = create((...a) => ({
// TODO: persist 옵션 추가
...userTypeSlice(...a),
...tokenSlice(...a),
...userNameSlice(...a),
...liveSlice(...a),
}));
const useBoundStore = create(
persist(
(...a) => ({
...userTypeSlice(...a),
...tokenSlice(...a),
...userNameSlice(...a),
}),
{ name: 'bound-store' }
)
);
export default useBoundStore;

View File

@ -1,6 +0,0 @@
// liveTabStatus: 'chat' | 'quiz'
export const liveSlice = (set) => ({
liveTabStatus: 'chat',
setLiveTabStatus: (liveTabStatus) => set({ liveTabStatus }),
});

View File

@ -6,7 +6,6 @@ const instance = axios.create({
timeout: 1000,
headers: {
'Content-type': 'application/json;charset=utf-8',
'Access-Control-Allow-Origin': import.meta.env.VITE_ORIGIN,
},
withCredentials: true,
});
@ -28,16 +27,25 @@ instance.interceptors.response.use(
return Promise.reject(error);
}
// TODO: api url update
const REFRESH_API_URL = '/refresh-token';
const REFRESH_API_URL = '/user/refresh';
return instance.post(REFRESH_API_URL).then((response) => {
const { accessToken } = response.data;
return instance
.post(REFRESH_API_URL)
.then((response) => {
const { accessToken } = response.data;
useBoundStore.setState({ token: accessToken });
error.config.headers.Authorization = `${accessToken}`;
return instance(error.config);
});
console.log(accessToken);
useBoundStore.setState({ token: accessToken });
error.config.headers.Authorization = `${accessToken}`;
return instance(error.config);
})
.catch((error) => {
useBoundStore.setState({ token: null });
console.log(error);
console.log('---로그아웃----');
// TODO: redirect to home
return Promise.reject(error);
});
}
);

View File

@ -1,8 +1,11 @@
import { Client } from '@stomp/stompjs';
import useBoundStore from '../../store';
export const chatClient = new Client({
brokerURL: import.meta.env.VITE_CHAT_URL,
// TODO: debug 제거
debug: (str) => console.log(str),
reconnectDelay: 5000,
debug: import.meta.env.DEV ? (str) => console.log(str) : () => {},
reconnectDelay: 3000,
connectHeaders: {
Authorization: useBoundStore.getState().token,
},
});