feat: 라이브 강의 페이지 추가
This commit is contained in:
parent
f7f7554545
commit
f841bf8b3f
@ -6,9 +6,9 @@ import NotFoundPage from './pages/NotFoundPage';
|
||||
import { lazy } from 'react';
|
||||
import MyPageLayout from './components/Layout/MyPageLayout';
|
||||
import LearningLecturesPage from './pages/LearningLecturesPage/LearningLecturesPage';
|
||||
import ChatRoom from './components/ChatRoom/ChatRoom';
|
||||
import { MaxWidthLayout } 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'));
|
||||
const NoticeListPage = lazy(async () => await import('./pages/NoticeListPage'));
|
||||
@ -25,6 +25,16 @@ const MyInfoChangePage = lazy(async () => await import('./pages/MyInfoChangePage
|
||||
const PasswordChangePage = lazy(async () => await import('./pages/PasswordChangePage'));
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: 'live/:roomId',
|
||||
element: <LiveLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <LivePage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
element: <PageLayout />,
|
||||
@ -38,17 +48,6 @@ const router = createBrowserRouter([
|
||||
path: 'lecture/:lectureId/info',
|
||||
element: <LectureInfoPage />,
|
||||
},
|
||||
{
|
||||
// TODO: 채팅 분리
|
||||
path: 'chat/:lectureId',
|
||||
element: (
|
||||
<MaxWidthLayout>
|
||||
<main>
|
||||
<ChatRoom />
|
||||
</main>
|
||||
</MaxWidthLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'lecture/:lectureId',
|
||||
element: <LectureLayout />,
|
||||
|
@ -4,13 +4,16 @@ import SendIcon from '/src/assets/icons/send.svg?react';
|
||||
import useChatRoom from '../../hooks/chat/useChatRoom';
|
||||
|
||||
export default function ChatRoom() {
|
||||
const { lectureId } = useParams();
|
||||
const { messages, handleSubmit, inputRef } = useChatRoom(lectureId);
|
||||
const { roomId } = useParams();
|
||||
const { messages, handleSubmit, inputRef, chatListRef } = useChatRoom(roomId);
|
||||
|
||||
return (
|
||||
<section className={styles.room}>
|
||||
<h2 className={styles.title}>채팅</h2>
|
||||
<ol className={styles.messageList}>
|
||||
<ol
|
||||
className={styles.messageList}
|
||||
ref={chatListRef}
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<li
|
||||
key={message.id}
|
||||
|
@ -1,37 +1,32 @@
|
||||
.room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
margin: 0;
|
||||
/* padding: 16px 16px 32px; */
|
||||
padding: 16px;
|
||||
background: linear-gradient(0deg, var(--whiteOpacity50) 0%, var(--background) 50%);
|
||||
/* background-color: var(--whiteOpacity500);
|
||||
backdrop-filter: blur(16px); */
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.messageList {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@keyframes show {
|
||||
@ -50,7 +45,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: show 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
max-width: 100%;
|
||||
padding: 0 16px 0 0;
|
||||
word-break: break-word;
|
||||
transform-origin: left bottom;
|
||||
box-sizing: border-box;
|
||||
|
||||
& > .bubble {
|
||||
padding: 8px 16px;
|
||||
@ -65,8 +64,8 @@
|
||||
}
|
||||
|
||||
.my {
|
||||
padding: 0 0 0 16px;
|
||||
align-self: end;
|
||||
align-items: end;
|
||||
transform-origin: right bottom;
|
||||
|
||||
& > .name {
|
||||
@ -97,12 +96,12 @@
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
z-index: 9;
|
||||
|
||||
& > input[type='text'] {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
padding: 12px 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: var(--text-color);
|
||||
@ -110,6 +109,7 @@
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
& > button {
|
||||
|
14
frontend/src/components/Layout/LiveLayout.jsx
Normal file
14
frontend/src/components/Layout/LiveLayout.jsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import styles from './LiveLayout.module.css';
|
||||
import { LiveHeader } from '../LiveHeader';
|
||||
|
||||
export default function LiveLayout() {
|
||||
return (
|
||||
<>
|
||||
<LiveHeader />
|
||||
<div className={styles.wrapper}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
24
frontend/src/components/Layout/LiveLayout.module.css
Normal file
24
frontend/src/components/Layout/LiveLayout.module.css
Normal file
@ -0,0 +1,24 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding-top: 64px;
|
||||
box-sizing: border-box;
|
||||
|
||||
& > main {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
& > aside {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 320px;
|
||||
height: 100%;
|
||||
background-color: var(--background);
|
||||
border-left: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
export { default as PageLayout } from './PageLayout';
|
||||
export { default as MaxWidthLayout } from './MaxWidthLayout';
|
||||
export { default as LiveLayout } from './LiveLayout';
|
||||
|
26
frontend/src/components/LiveHeader/LiveHeader.jsx
Normal file
26
frontend/src/components/LiveHeader/LiveHeader.jsx
Normal file
@ -0,0 +1,26 @@
|
||||
import styles from './LiveHeader.module.css';
|
||||
|
||||
export default function LiveHeader() {
|
||||
const { data } = {
|
||||
data: {
|
||||
title: '정보처리기사 실기 완전정복',
|
||||
subtitle: '2차시',
|
||||
participants: 3,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.area}>
|
||||
<h1>{data.title}</h1>
|
||||
<div>{data.subtitle}</div>
|
||||
</div>
|
||||
<div className={styles.area}>
|
||||
<h2>참가자 수</h2>
|
||||
<div>{data.participants}명</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
41
frontend/src/components/LiveHeader/LiveHeader.module.css
Normal file
41
frontend/src/components/LiveHeader/LiveHeader.module.css
Normal file
@ -0,0 +1,41 @@
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background-color: var(--background);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 40px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
& > h1,
|
||||
& > h2 {
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& > div {
|
||||
font-weight: 400;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
}
|
1
frontend/src/components/LiveHeader/index.js
Normal file
1
frontend/src/components/LiveHeader/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default as LiveHeader } from './LiveHeader';
|
@ -9,6 +9,7 @@ export default function useChatRoom(roomId) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const userName = useBoundStore((state) => state.userName) ?? '익명';
|
||||
const inputRef = useRef(null);
|
||||
const chatListRef = useRef(null);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
@ -34,6 +35,7 @@ export default function useChatRoom(roomId) {
|
||||
client.subscribe(`/sub/channel/${roomId}`, (response) => {
|
||||
const data = JSON.parse(response.body);
|
||||
const { content: message, name } = data;
|
||||
|
||||
setMessages((prev) => [...prev, { id: prev.length, text: message, isMine: USER_ID === data.userId, name }]);
|
||||
});
|
||||
};
|
||||
@ -44,9 +46,16 @@ export default function useChatRoom(roomId) {
|
||||
};
|
||||
}, [client, roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatListRef.current.scrollHeight - chatListRef.current.scrollTop - chatListRef.current.clientHeight < 200) {
|
||||
chatListRef.current.scrollTop = chatListRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
inputRef,
|
||||
handleSubmit,
|
||||
chatListRef,
|
||||
};
|
||||
}
|
||||
|
14
frontend/src/pages/LivePage/LivePage.jsx
Normal file
14
frontend/src/pages/LivePage/LivePage.jsx
Normal file
@ -0,0 +1,14 @@
|
||||
import useBoundStore from '../../store';
|
||||
import ChatRoom from '../../components/ChatRoom/ChatRoom';
|
||||
|
||||
export default function LivePage() {
|
||||
const liveTabStatus = useBoundStore((state) => state.liveTabStatus);
|
||||
// const setLiveTabStatus = useBoundStore((state) => state.setLiveTabStatus);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main></main>
|
||||
<aside>{liveTabStatus === 'chat' ? <ChatRoom /> : <div>Quiz</div>}</aside>
|
||||
</>
|
||||
);
|
||||
}
|
1
frontend/src/pages/LivePage/index.js
Normal file
1
frontend/src/pages/LivePage/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './LivePage';
|
@ -1,11 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
import { userTypeSlice } from './userTypeSlice';
|
||||
import { tokenSlice } from './tokenSlice';
|
||||
import { userNameSlice } from './userNameSlice';
|
||||
import { liveSlice } from './liveSlice';
|
||||
|
||||
const useBoundStore = create((...a) => ({
|
||||
// TODO: persist 옵션 추가
|
||||
...userTypeSlice(...a),
|
||||
...tokenSlice(...a),
|
||||
...userNameSlice(...a),
|
||||
...liveSlice(...a),
|
||||
}));
|
||||
|
||||
export default useBoundStore;
|
||||
|
6
frontend/src/store/liveSlice.js
Normal file
6
frontend/src/store/liveSlice.js
Normal file
@ -0,0 +1,6 @@
|
||||
// liveTabStatus: 'chat' | 'quiz'
|
||||
|
||||
export const liveSlice = (set) => ({
|
||||
liveTabStatus: 'chat',
|
||||
setLiveTabStatus: (liveTabStatus) => set({ liveTabStatus }),
|
||||
});
|
Loading…
Reference in New Issue
Block a user