Merge branch 'frontend' into 'FE/QuizCard'

# Conflicts:
#   frontend/src/components/ClassCard/ClassCard.jsx
This commit is contained in:
정기영 2024-08-05 17:52:06 +09:00
commit 3a245781f5
28 changed files with 265 additions and 47 deletions

View File

@ -7,8 +7,8 @@ import { lazy } from 'react';
import MyPageLayout from './components/Layout/MyPageLayout';
import LivePage from './pages/LivePage';
import ErrorPage from './pages/ErrorPage';
import { LectureLayout } from './components/Layout';
const LectureLayout = lazy(async () => await import('./components/Layout/LectureLayout'));
const LearningLectureDetailPage = lazy(async () => await import('./pages/LearningLectureDetailPage'));
const NoticeListPage = lazy(async () => await import('./pages/NoticeListPage'));
const NoticeDetailPage = lazy(async () => await import('./pages/NoticeDetailPage'));
@ -30,6 +30,7 @@ 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 LectureEnrollPage = lazy(async () => await import('./pages/LectureEnrollPage'));
const QuizsetEditPage = lazy(async () => await import('./pages/QuizsetEditPage'));
const router = createBrowserRouter([
@ -156,6 +157,10 @@ const router = createBrowserRouter([
},
],
},
{
path: 'enroll',
element: <LectureEnrollPage />,
},
],
},
{

View File

@ -0,0 +1,8 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Compass">
<g id="Icon">
<path d="M24.5 44.5C35.5457 44.5 44.5 35.5457 44.5 24.5C44.5 13.4543 35.5457 4.5 24.5 4.5C13.4543 4.5 4.5 13.4543 4.5 24.5C4.5 35.5457 13.4543 44.5 24.5 44.5Z" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.98 16.02L28.74 28.74L16.02 32.98L20.26 20.26L32.98 16.02Z" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 510 B

View File

@ -0,0 +1,5 @@
<svg width="49" height="49" viewBox="0 0 49 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Plus circle">
<path id="Icon" d="M24.5 16.5V32.5M16.5 24.5H32.5M44.5 24.5C44.5 35.5457 35.5457 44.5 24.5 44.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5C35.5457 4.5 44.5 13.4543 44.5 24.5Z" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 395 B

View File

@ -1,5 +1,6 @@
import { Link } from 'react-router-dom';
import styles from './ClassCard.module.css';
import CompassIcon from '/src/assets/icons/compass.svg?react';
export default function ClassCard({ path, children }) {
return (
@ -7,7 +8,17 @@ export default function ClassCard({ path, children }) {
to={path}
className={styles.card}
>
<div className={styles.thumbnail} />
{img ? (
<img
src={img}
alt="강의 이미지"
className={styles.thumbnail}
/>
) : (
<div className={styles.thumbnail}>
<CompassIcon />
</div>
)}
<div>{children}</div>
</Link>
);

View File

@ -9,9 +9,13 @@
}
.thumbnail {
display: flex;
justify-content: center;
align-items: center;
width: 295px;
height: 220px;
border-radius: 20px;
background-color: var(--background-secondary);
stroke: var(--text-color);
box-sizing: border-box;
}

View File

@ -33,7 +33,12 @@ export default function Header() {
<Link to={'/'}>수강중인 강의</Link>
</li>
<li>
<Link to={'/live/1'}>live</Link>
<Link
to={'/live/1'}
target="_blank"
>
live
</Link>
</li>
</ul>
<ul className={styles.group}>

View File

@ -52,6 +52,7 @@ export default function LectureLayout() {
<SideLink to={'qna'}>Q&A</SideLink>
<SideLink to={'file'}>수업자료</SideLink>
<SideLink to={'quiz'}>퀴즈</SideLink>
{userType === 'teacher' && <SideLink to={'enroll'}>수강신청관리</SideLink>}
</SideBar>
{userType === 'teacher' && (
<SideBar title="수업 정보">

View File

@ -1,3 +1,4 @@
export { default as PageLayout } from './PageLayout';
export { default as MaxWidthLayout } from './MaxWidthLayout';
export { default as LiveLayout } from './LiveLayout';
export { default as LectureLayout } from './LectureLayout';

View File

@ -0,0 +1,28 @@
import styles from './LectureEnroll.module.css';
import { useLectureEnrollCancel } from '../../hooks/api/useLectureEnrollCancel';
import { useLectureEnrollAccept } from '../../hooks/api/useLectureEnrollAccept';
export default function LectureEnroll({ userName, enrollid, onDelete }) {
const { lectureEnrollCancel } = useLectureEnrollCancel();
const { lectureEnrollAccept } = useLectureEnrollAccept();
const handleAccept = async (e) => {
e.preventDefault();
await lectureEnrollAccept(enrollid);
onDelete(enrollid);
};
const handleCancel = async (e) => {
e.preventDefault();
await lectureEnrollCancel(enrollid);
onDelete(enrollid);
};
return (
<div className={styles.enrollLink}>
<p>{userName}</p>
<button onClick={handleAccept}>등록</button>
<button onClick={handleCancel}>삭제</button>
</div>
);
}

View File

@ -0,0 +1,13 @@
.enrollLink {
border-radius: 8px;
width: 100%;
display: flex;
justify-content: space-between;
box-sizing: border-box;
padding: 16px 20px;
transition: background-color 0.25s;
}
.enrollLink:hover {
background-color: var(--background-secondary);
}

View File

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

View File

@ -1,35 +1,54 @@
import { Link, useParams } from 'react-router-dom';
import styles from './LectureHeader.module.css';
import PlayIcon from '/src/assets/icons/play.svg?react';
import CompassIcon from '/src/assets/icons/compass.svg?react';
import UserIcon from '/src/assets/icons/user.svg?react';
export default function LectureHeader({ img, title, tutorImg, tutor, isLive = false }) {
const { lectureId } = useParams();
return (
<div className={styles.wrapper}>
<header className={styles.header}>
<img
src={img}
alt="강의 이미지"
className={styles.thumbnail}
/>
{img ? (
<img
src={img}
alt="강의 이미지"
className={styles.thumbnail}
/>
) : (
<div className={styles.thumbnail}>
<CompassIcon />
</div>
)}
<div className={styles.info}>
<h1 className={styles.title}>{title}</h1>
<div className={styles.desc}>
<div className={styles.tutor}>
<img
src={tutorImg}
alt="강사 사진"
className={styles.tutorImg}
/>
{tutorImg ? (
<img
src={tutorImg}
alt="강사 사진"
className={styles.tutorImg}
/>
) : (
<div className={styles.tutorImg}>
<UserIcon />
</div>
)}
<div>{tutor}</div>
</div>
<div>
{isLive ? (
<button
<Link
to={`/live/${lectureId}`}
target="_blank"
type="button"
className={styles.liveButton}
>
<PlayIcon />
<span>실시간 강의 입장하기</span>
</button>
</Link>
) : null}
</div>
</div>

View File

@ -20,9 +20,13 @@
.thumbnail {
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
width: 120px;
height: 120px;
border-radius: 16px;
stroke: var(--text-color);
border: 1px solid var(--border-color);
}
@ -53,9 +57,13 @@
}
.tutorImg {
display: flex;
justify-content: center;
align-items: center;
width: 48px;
height: 48px;
border-radius: 50%;
stroke: var(--text-color-secondary);
border: 1px solid var(--border-color);
}

View File

@ -15,17 +15,19 @@ import {
useRoomInfo,
useTracks,
useParticipants,
useLocalParticipant,
} from '@livekit/components-react';
import { RoomEvent, Track } from 'livekit-client';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import ChatRoom from '../ChatRoom/ChatRoom';
export default function LiveRoom() {
const lastAutoFocusedScreenShareTrack = useRef(null);
const [role, setRole] = useState(null);
// get livekit identity
const room = useRoomInfo();
const participants = useParticipants();
const { localParticipant } = useLocalParticipant();
const tracks = useTracks(
[
@ -43,6 +45,16 @@ export default function LiveRoom() {
const focusTrack = usePinnedTracks(layoutContext)?.[0];
const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
useEffect(() => {
try {
const role = JSON.parse(localParticipant.identity).role;
setRole(role);
} catch (_) {
return;
}
}, [localParticipant.identity]);
useEffect(() => {
if (
screenShareTracks.some((track) => track.publication.isSubscribed) &&
@ -103,7 +115,10 @@ export default function LiveRoom() {
</FocusLayoutContainer>
</div>
)}
<ControlBar controls={{ chat: false, leave: false }} />
<ControlBar
variation="minimal"
controls={{ chat: false, leave: true, screenShare: role === '강사' }}
/>
</div>
<ChatRoom />
</LayoutContextProvider>

View File

@ -18,7 +18,7 @@
.groupList {
display: flex;
flex-direction: column;
gap: 16px;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;

View File

@ -15,5 +15,4 @@
.active {
font-weight: 700;
text-decoration: underline;
}

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 useLectureEnroll(lectureId) {
return useSuspenseQuery({
queryKey: ['lecturelist', lectureId],
queryFn: () => instance.get(`${API_URL}/registration/${lectureId}`),
});
}

View File

@ -0,0 +1,9 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useLectureEnrollAccept() {
const lectureEnrollAccept = (enrollId) => {
return instance.put(`${API_URL}/registration/${enrollId}`);
};
return { lectureEnrollAccept };
}

View File

@ -0,0 +1,9 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useLectureEnrollCancel() {
const lectureEnrollCancel = (enrollId) => {
return instance.delete(`${API_URL}/registration/${enrollId}`);
};
return { lectureEnrollCancel };
}

View File

@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useNotices(lectureId, page = 0) {
return useQuery({
return useSuspenseQuery({
queryKey: ['noticelist', lectureId, page],
queryFn: () => instance.get(`${API_URL}/board?lectureId=${lectureId}&category=announcement&pageNo=${page}`),
});

View File

@ -0,0 +1,40 @@
import ArticleBoard from '../../components/ArticleBoard/ArticleBoard';
import LectureEnroll from '../../components/LectureEnroll/LectureEnroll';
import { useParams } from 'react-router-dom';
import { useLectureEnroll } from '../../hooks/api/useLectureEnroll';
import { useState, useEffect } from 'react';
export default function QuestionListPage() {
const { lectureId } = useParams();
const { data } = useLectureEnroll(lectureId);
const [lectures, setLectures] = useState([]);
useEffect(() => {
if (data?.data) {
setLectures(data.data);
}
}, [data]);
const handleDelete = async (enrollId) => {
setLectures(lectures.filter((lecture) => lecture.id !== enrollId));
};
return (
<ArticleBoard
title="수강신청관리"
canCreate={false}
>
{lectures.map?.((lecture) => (
<LectureEnroll
key={`${lecture.id}`}
enrollid={lecture.id}
userName={lecture.userName}
onDelete={handleDelete}
/>
))}
</ArticleBoard>
);
}
//FIXME: .
// FIXME: HEADER .

View File

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

View File

@ -25,7 +25,9 @@ export default function LectureInfoPage() {
lectureRegister(lectureId)
.then(() => {
navigate(`/lecture/${lectureId}`);
// navigate(`/lecture/${lectureId}`);
window.alert('강사가 수강신청 수락시 수업이 시작됩니다.');
navigate('/');
})
.catch((err) => {
console.log(err);

View File

@ -1,31 +1,30 @@
import { LiveRoom } from '../../components/LiveRoom';
import { useParams } from 'react-router-dom';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } 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';
import LoadingIndicator from '../../components/LoadingIndicator.jsx/LoadingIndicator';
export default function LivePage() {
const { roomId } = useParams();
const [liveToken, setLiveToken] = useState(null);
const generateToken = useCallback(async () => {
await instance.post(`${API_URL}/video/makeroom/${roomId}`);
const { data } = await instance.post(`${API_URL}/video/joinroom/${roomId}`);
await instance.post(`${API_URL}/video/makeroom/${roomId}`).catch(() => {});
const { data } = await instance.post(`${API_URL}/video/joinroom/${roomId}`).catch(() => {
alert('방에 입장할 수 없습니다.');
window.close();
});
return data.token;
}, [roomId]);
const liveToken = useBoundStore((state) => state.liveToken);
useEffect(() => {
if (!liveToken) {
generateToken().then((token) => {
useBoundStore.setState({ liveToken: token });
});
}
}, [generateToken, liveToken]);
generateToken().then((token) => {
setLiveToken(token);
});
}, [generateToken]);
return liveToken ? (
<LiveKitRoom
@ -33,6 +32,10 @@ export default function LivePage() {
serverUrl={ROOM_URL}
connect={true}
data-lk-theme="default"
onDisconnected={() => {
instance.post(`${API_URL}/video/deleteroom/${roomId}`).catch(() => {});
window.close();
}}
>
<LiveRoom />
</LiveKitRoom>

View File

@ -1,17 +1,19 @@
import styles from './TeacherHomePage.module.css';
import { Link } from 'react-router-dom';
import { ClassCard } from '../../components/ClassCard';
import { ClassGrid } from '../../components/ClassGrid';
import { MaxWidthLayout } from '../../components/Layout';
import { useMyLectures } from '../../hooks/api/useMyLectures';
import PlusIcon from '/src/assets/icons/plus.svg?react';
export default function TeacherHomePage() {
const { data: myLectures } = useMyLectures();
const onGoingClasses = myLectures?.data ?? [];
console.log(onGoingClasses);
// TODO: ,
return (
<MaxWidthLayout>
<ClassGrid title="내 강의">
{onGoingClasses.map?.((lecture) => (
{onGoingClasses.map((lecture) => (
<ClassCard
key={lecture.id}
path={`/lecture/${lecture.id}`}
@ -20,7 +22,13 @@ export default function TeacherHomePage() {
{lecture.title}
</ClassCard>
))}
<ClassCard path={'/lecture/create'}> 강의 만들기</ClassCard>
<Link
to={'/lecture/create'}
className={styles.add}
>
<PlusIcon />
<span> 강의 만들기</span>
</Link>
</ClassGrid>
</MaxWidthLayout>
);

View File

@ -0,0 +1,21 @@
.add {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
margin-bottom: 32px;
background-color: var(--background);
color: var(--text-color);
stroke: var(--text-color);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 1.4;
border-radius: 20px;
transition: background-color 0.2s ease;
&:hover {
background-color: var(--background-secondary);
}
}

View File

@ -3,7 +3,6 @@ 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(
@ -11,7 +10,6 @@ const useBoundStore = create(
...userTypeSlice(...a),
...tokenSlice(...a),
...userNameSlice(...a),
...liveSlice(...a),
}),
{ name: 'bound-store' }
)

View File

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