diff --git a/frontend/src/components/session/SessionHeader.tsx b/frontend/src/components/session/SessionHeader.tsx index 09d1f4d7..e336ca9e 100644 --- a/frontend/src/components/session/SessionHeader.tsx +++ b/frontend/src/components/session/SessionHeader.tsx @@ -1,4 +1,5 @@ import { RoomMetadata } from "@hooks/type/session"; +import { useEffect, useState } from "react"; interface SessionHeaderProps { roomMetadata: RoomMetadata | null; @@ -9,6 +10,19 @@ const SessionHeader = ({ participantsCount, roomMetadata, }: SessionHeaderProps) => { + const [uptime, setUptime] = useState(0); + const SECOND = 1000; + useEffect(() => { + if (!roomMetadata?.inProgress) return; + const interval = setInterval(() => { + setUptime((prev) => prev + 1); + }, SECOND); + + return () => { + clearInterval(interval); + }; + }, [roomMetadata?.inProgress]); + return (
{roomMetadata?.title ? ( <> - {" "} {roomMetadata?.category} @@ -26,6 +39,24 @@ const SessionHeader = ({ {roomMetadata && `(${participantsCount} / ${roomMetadata.maxParticipants})`} + {roomMetadata.inProgress ? ( + + + 스터디 진행 중 + + {Math.floor(uptime / 60)}분 {uptime % 60}초 + + + ) : ( + + + 스터디 시작 전 + + )} ) : (

아직 세션에 참가하지 않았습니다.

diff --git a/frontend/src/components/session/SessionSidebar.tsx b/frontend/src/components/session/SessionSidebar.tsx deleted file mode 100644 index c0f7b23e..00000000 --- a/frontend/src/components/session/SessionSidebar.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { FaClipboardList } from "react-icons/fa"; -import { FaUserGroup } from "react-icons/fa6"; -import useModalStore from "../../stores/useModalStore"; -import Modal from "../common/Modal"; -import { useNavigate } from "react-router-dom"; -import { Socket } from "socket.io-client"; -import useToast from "../../hooks/useToast"; -import { TbCrown } from "react-icons/tb"; -import { SESSION_EMIT_EVENT } from "@/constants/WebSocket/SessionEvent.ts"; - -interface ParticipantsData { - nickname: string; - isHost: boolean; -} - -interface Props { - socket: Socket | null; - question: string; - participants: ParticipantsData[]; - roomId: string | undefined; // TODO: sessionId가 입력되지 않았을 때(undefined) 처리 필요 - isHost: boolean; -} - -const SessionSidebar = ({ - socket, - question, - participants, - roomId, - isHost, -}: Props) => { - const { openModal } = useModalStore(); - const navigate = useNavigate(); - const toast = useToast(); - - const existHandler = () => { - socket?.emit(SESSION_EMIT_EVENT.LEAVE, { roomId }); - toast.success("메인 화면으로 이동합니다."); - navigate("/sessions"); - }; - - const destroyAndExitHandler = () => { - socket?.off(SESSION_EMIT_EVENT.FINISH); - socket?.emit(SESSION_EMIT_EVENT.FINISH, { roomId }); - toast.success("메인 화면으로 이동합니다."); - navigate("/sessions"); - }; - - const HostModalData = { - title: "세션을 종료할까요?", - subtitle: "세션을 종료하면 참가자들이 모두 나가게 됩니다.", - leftButton: "방장 양도 후 종료", - rightButton: "세션 종료", - type: "red", - onLeftClick: existHandler, - onRightClick: destroyAndExitHandler, - }; - - const ParticipantModalData = { - title: "지금 나가면 다시 들어올 수 없어요!", - subtitle: "정말 종료하시겠어요?", - leftButton: "취소하기", - rightButton: "종료하기", - type: "red", - onLeftClick: () => {}, - onRightClick: existHandler, - }; - - return ( -
- -
-
-

- - 질문 -

-

- {question} -

-
-
-

- - 참가자 -

-
    - {participants.map((participant, index) => ( -
  • - - {participant.nickname} - - {participant.isHost && } - -
  • - ))} -
-
-
-
- -
-
- ); -}; - -export default SessionSidebar; diff --git a/frontend/src/components/session/Sidebar/SessionSidebar.tsx b/frontend/src/components/session/Sidebar/SessionSidebar.tsx new file mode 100644 index 00000000..3b9e1606 --- /dev/null +++ b/frontend/src/components/session/Sidebar/SessionSidebar.tsx @@ -0,0 +1,177 @@ +import { FaClipboardList, FaFolder } from "react-icons/fa"; +import { FaUserGroup } from "react-icons/fa6"; +import useModalStore from "@stores/useModalStore.ts"; +import Modal from "../../common/Modal"; +import { useNavigate } from "react-router-dom"; +import { Socket } from "socket.io-client"; +import useToast from "@hooks/useToast.ts"; +import { TbCrown } from "react-icons/tb"; +import { SESSION_EMIT_EVENT } from "@/constants/WebSocket/SessionEvent.ts"; +import { Question } from "@hooks/type/session"; + +interface ParticipantsData { + nickname: string; + isHost: boolean; +} + +interface Props { + socket: Socket | null; + questionList: Question[]; + currentIndex: number; + participants: ParticipantsData[]; + roomId: string | undefined; // TODO: sessionId가 입력되지 않았을 때(undefined) 처리 필요 + isHost: boolean; +} + +const SessionSidebar = ({ + socket, + questionList, + currentIndex, + participants, + roomId, + isHost, +}: Props) => { + const { openModal } = useModalStore(); + const navigate = useNavigate(); + const toast = useToast(); + + const existHandler = () => { + socket?.emit(SESSION_EMIT_EVENT.LEAVE, { roomId }); + toast.success("메인 화면으로 이동합니다."); + navigate("/sessions"); + }; + + const destroyAndExitHandler = () => { + socket?.off(SESSION_EMIT_EVENT.FINISH); + socket?.emit(SESSION_EMIT_EVENT.FINISH, { roomId }); + toast.success("메인 화면으로 이동합니다."); + navigate("/sessions"); + }; + + const HostModalData = { + title: "세션을 종료할까요?", + subtitle: "세션을 종료하면 참가자들이 모두 나가게 됩니다.", + leftButton: "방장 양도 후 종료", + rightButton: "세션 종료", + type: "red", + onLeftClick: existHandler, + onRightClick: destroyAndExitHandler, + }; + + const ParticipantModalData = { + title: "지금 나가면 다시 들어올 수 없어요!", + subtitle: "정말 종료하시겠어요?", + leftButton: "취소하기", + rightButton: "종료하기", + type: "red", + onLeftClick: () => {}, + onRightClick: existHandler, + }; + + return ( +
+
+
+
+

+ + 현재 질문 +

+
+ {currentIndex >= 0 ? ( +

+ + Q{questionList[currentIndex].index + 1}.{" "} + + {questionList[currentIndex].content} +

+ ) : ( +

질문 로딩 중...

+ )} +
+
+
+

+ + 참가자 +

+
    + {participants.map((participant, index) => ( +
  • + + {participant.nickname} + + {participant.isHost && } + +
  • + ))} +
+
+
+

+ + 이전 질문 +

+
    + {currentIndex <= 0 && ( +
  • + 여기에 이전 질문이 기록됩니다. +
  • + )} + {questionList.map((question, index) => { + if (index < currentIndex) + return ( +
  • + Q{index + 1}. {question.content} +
  • + ); + })} +
+
+
+
+ +
+
+ + +
+ ); +}; + +export default SessionSidebar; diff --git a/frontend/src/components/session/Sidebar/SidebarContainer.tsx b/frontend/src/components/session/Sidebar/SidebarContainer.tsx new file mode 100644 index 00000000..6b1cf96e --- /dev/null +++ b/frontend/src/components/session/Sidebar/SidebarContainer.tsx @@ -0,0 +1,32 @@ +import { ReactNode, useState } from "react"; +import { FaArrowLeft } from "react-icons/fa"; + +interface SidebarContainerProps { + children: ReactNode; +} + +const SidebarContainer = ({ children }: SidebarContainerProps) => { + const [isCollapsed, setIsCollapsed] = useState(false); + return ( +
+
+ {children} +
+ +
+ ); +}; + +export default SidebarContainer; diff --git a/frontend/src/components/session/SessionToolbar.tsx b/frontend/src/components/session/Toolbar/CommonTools.tsx similarity index 82% rename from frontend/src/components/session/SessionToolbar.tsx rename to frontend/src/components/session/Toolbar/CommonTools.tsx index 6df53f3d..3badd676 100644 --- a/frontend/src/components/session/SessionToolbar.tsx +++ b/frontend/src/components/session/Toolbar/CommonTools.tsx @@ -1,13 +1,12 @@ -import { FaAngleLeft, FaAngleRight } from "react-icons/fa6"; import { BsCameraVideo, BsCameraVideoOff, + BsHandThumbsUp, BsMic, BsMicMute, - BsHandThumbsUp, } from "react-icons/bs"; -interface Props { +interface CommonToolsProps { handleVideoToggle: () => void; handleMicToggle: () => void; emitReaction: (reactionType: string) => void; @@ -19,7 +18,7 @@ interface Props { isMicOn: boolean; videoLoading: boolean; } -const SessionToolbar = ({ +const CommonTools = ({ handleVideoToggle, handleMicToggle, emitReaction, @@ -30,19 +29,9 @@ const SessionToolbar = ({ isVideoOn, isMicOn, videoLoading, -}: Props) => { +}: CommonToolsProps) => { return ( -
- + <>
- -
+ ); }; -export default SessionToolbar; +export default CommonTools; diff --git a/frontend/src/components/session/Toolbar/HostOnlyTools.tsx b/frontend/src/components/session/Toolbar/HostOnlyTools.tsx new file mode 100644 index 00000000..72f2ac74 --- /dev/null +++ b/frontend/src/components/session/Toolbar/HostOnlyTools.tsx @@ -0,0 +1,119 @@ +import { FaAngleLeft, FaAngleRight } from "react-icons/fa6"; +import { useEffect, useState } from "react"; + +// 툴바에서 호스트만 사용가능 도구들 분리 +interface HostOnlyToolsProps { + isHost: boolean; + isInProgress: boolean; + stopStudySession: () => void; + startStudySession: () => void; + requestChangeIndex: (type: "next" | "prev") => void; + currentIndex: number; + maxQuestionLength: number; +} +const HostOnlyTools = ({ + isHost, + isInProgress, + stopStudySession, + startStudySession, + requestChangeIndex, + currentIndex, + maxQuestionLength, +}: HostOnlyToolsProps) => { + const [changeCooldown, setChangeCooldown] = useState(false); + const COOLDOWN_TIME = 2000; + + useEffect(() => { + if (!changeCooldown) return; + + const timeout = setTimeout(() => { + setChangeCooldown(false); + }, COOLDOWN_TIME); + + return () => { + clearTimeout(timeout); + }; + }, [changeCooldown]); + + return ( + isHost && ( + <> + {isInProgress ? ( +
+ +
+ ) : ( +
+ +
+ )} + {isInProgress && ( +
+ + +
+ )} + + ) + ); +}; + +export default HostOnlyTools; diff --git a/frontend/src/components/session/Toolbar/SessionToolbar.tsx b/frontend/src/components/session/Toolbar/SessionToolbar.tsx new file mode 100644 index 00000000..f7c0ab48 --- /dev/null +++ b/frontend/src/components/session/Toolbar/SessionToolbar.tsx @@ -0,0 +1,76 @@ +import HostOnlyTools from "@components/session/Toolbar/HostOnlyTools.tsx"; +import CommonTools from "@components/session/Toolbar/CommonTools.tsx"; + +interface Props { + requestChangeIndex: ( + type: "next" | "prev" | "current" | "move", + index?: number + ) => void; + handleVideoToggle: () => void; + handleMicToggle: () => void; + emitReaction: (reactionType: string) => void; + userVideoDevices: MediaDeviceInfo[]; + userAudioDevices: MediaDeviceInfo[]; + setSelectedVideoDeviceId: (deviceId: string) => void; + setSelectedAudioDeviceId: (deviceId: string) => void; + isVideoOn: boolean; + isMicOn: boolean; + videoLoading: boolean; + isHost: boolean; + isInProgress: boolean; + startStudySession: () => void; + stopStudySession: () => void; + currentIndex: number; + maxQuestionLength: number; +} +const SessionToolbar = ({ + requestChangeIndex, + handleVideoToggle, + handleMicToggle, + emitReaction, + userVideoDevices, + userAudioDevices, + setSelectedVideoDeviceId, + setSelectedAudioDeviceId, + isVideoOn, + isMicOn, + videoLoading, + isHost, + isInProgress, + startStudySession, + stopStudySession, + currentIndex, + maxQuestionLength, +}: Props) => { + return ( +
+ + +
+ ); +}; + +export default SessionToolbar; diff --git a/frontend/src/constants/WebSocket/StudyEvent.ts b/frontend/src/constants/WebSocket/StudyEvent.ts new file mode 100644 index 00000000..1d7b130b --- /dev/null +++ b/frontend/src/constants/WebSocket/StudyEvent.ts @@ -0,0 +1,16 @@ +export const STUDY_EMIT_EVENT = { + NEXT: "client:study__next_question", + CURRENT: "client:study__current_index", + INDEX: "client:study__move_index", + START: "client:study__start_progress", + STOP: "client:study__stop_progress", +}; + +export const STUDY_LISTEN_EVENT = { + NEXT: "server:study__next_question", + CURRENT: "server:study__current_index", + INDEX: "server:study__move_index", + START: "server:study__start_progress", + STOP: "server:study__stop_progress", + PROGRESS: "server:room__progress", +}; diff --git a/frontend/src/hooks/session/useSession.ts b/frontend/src/hooks/session/useSession.ts index 7670ff27..85f1d8d1 100644 --- a/frontend/src/hooks/session/useSession.ts +++ b/frontend/src/hooks/session/useSession.ts @@ -11,6 +11,7 @@ import { useSocketEvents } from "./useSocketEvents"; import { Socket } from "socket.io-client"; import { SESSION_EMIT_EVENT } from "@/constants/WebSocket/SessionEvent"; import useAuth from "@hooks/useAuth"; +import useStudy from "@hooks/session/useStudy"; export const useSession = (sessionId: string) => { const { socket } = useSocket(); @@ -67,6 +68,13 @@ export const useSession = (sessionId: string) => { setPeers ); + const { requestChangeIndex, stopStudySession, startStudySession } = useStudy( + socket, + sessionId, + setRoomMetadata, + setPeers + ); + useSocketEvents({ socket, stream, @@ -153,5 +161,8 @@ export const useSession = (sessionId: string) => { emitReaction, videoLoading, peerMediaStatus, + requestChangeIndex, + startStudySession, + stopStudySession, }; }; diff --git a/frontend/src/hooks/session/useSocketEvents.ts b/frontend/src/hooks/session/useSocketEvents.ts index bc12df46..ed3f7e4d 100644 --- a/frontend/src/hooks/session/useSocketEvents.ts +++ b/frontend/src/hooks/session/useSocketEvents.ts @@ -14,12 +14,14 @@ import { ResponseMasterChanged, RoomMetadata, PeerConnection, + ProgressResponse, } from "@hooks/type/session"; import { SIGNAL_EMIT_EVENT, SIGNAL_LISTEN_EVENT, } from "@/constants/WebSocket/SignalingEvent"; import { SESSION_LISTEN_EVENT } from "@/constants/WebSocket/SessionEvent"; +import { STUDY_LISTEN_EVENT } from "@/constants/WebSocket/StudyEvent"; interface UseSocketEventsProps { socket: Socket | null; @@ -106,6 +108,9 @@ export const useSocketEvents = ({ status, title, connectionMap, + questionListId, + questionListContents, + currentIndex, } = data; const roomMetadata = { @@ -118,6 +123,9 @@ export const useSocketEvents = ({ maxParticipants, createdAt, inProgress, + questionListId, + questionListContents, + currentIndex, }; setRoomMetadata(roomMetadata); @@ -197,6 +205,30 @@ export const useSocketEvents = ({ navigate("/sessions"); }; + const handleRoomProgress = () => { + toast.error("해당 세션은 현재 진행 중입니다."); + navigate("/sessions"); + }; + + const handleChangeIndex = (data: { currentIndex: number }) => { + const { currentIndex } = data; + if (currentIndex >= 0) { + setRoomMetadata((prev) => ({ ...prev!, currentIndex })); + } + }; + + const handleProgress = (data: ProgressResponse) => { + const { status, inProgress } = data; + + if (status === "success") { + setRoomMetadata((prev) => ({ ...prev!, inProgress: inProgress })); + if (inProgress) toast.success("방장이 스터디를 시작했습니다."); + else toast.error("방장이 스터디를 중지했습니다."); + } else { + toast.error("세션 진행을 시작하지 못했습니다."); + } + }; + socket.on(SIGNAL_LISTEN_EVENT.OFFER, handleGetOffer); socket.on(SIGNAL_LISTEN_EVENT.ANSWER, handleGetAnswer); socket.on(SIGNAL_LISTEN_EVENT.CANDIDATE, handleGetCandidate); @@ -206,6 +238,12 @@ export const useSocketEvents = ({ socket.on(SESSION_LISTEN_EVENT.CHANGE_HOST, handleHostChange); socket.on(SESSION_LISTEN_EVENT.REACTION, handleReaction); socket.on(SESSION_LISTEN_EVENT.FINISH, handleRoomFinished); + socket.on(STUDY_LISTEN_EVENT.INDEX, handleChangeIndex); + socket.on(STUDY_LISTEN_EVENT.CURRENT, handleChangeIndex); + socket.on(STUDY_LISTEN_EVENT.NEXT, handleChangeIndex); + socket.on(STUDY_LISTEN_EVENT.START, handleProgress); + socket.on(STUDY_LISTEN_EVENT.STOP, handleProgress); + socket.on(STUDY_LISTEN_EVENT.PROGRESS, handleRoomProgress); return () => { socket.off(SIGNAL_LISTEN_EVENT.OFFER, handleGetOffer); @@ -217,6 +255,11 @@ export const useSocketEvents = ({ socket.off(SESSION_LISTEN_EVENT.CHANGE_HOST, handleHostChange); socket.off(SESSION_LISTEN_EVENT.REACTION, handleReaction); socket.off(SESSION_LISTEN_EVENT.FINISH, handleRoomFinished); + socket.off(STUDY_LISTEN_EVENT.INDEX, handleChangeIndex); + socket.off(STUDY_LISTEN_EVENT.CURRENT, handleChangeIndex); + socket.off(STUDY_LISTEN_EVENT.NEXT, handleChangeIndex); + socket.off(STUDY_LISTEN_EVENT.START, handleProgress); + socket.off(STUDY_LISTEN_EVENT.STOP, handleProgress); if (reactionTimeouts.current) { Object.values(reactionTimeouts.current).forEach(clearTimeout); diff --git a/frontend/src/hooks/session/useStudy.ts b/frontend/src/hooks/session/useStudy.ts new file mode 100644 index 00000000..eff50d71 --- /dev/null +++ b/frontend/src/hooks/session/useStudy.ts @@ -0,0 +1,46 @@ +import { STUDY_EMIT_EVENT } from "@/constants/WebSocket/StudyEvent.ts"; + +const useStudy = (socket, isHost, roomMetadata, sessionId) => { + const requestChangeIndex = ( + type: "next" | "prev" | "current" | "move", + index?: number + ) => { + if (socket) { + if (isHost && roomMetadata) { + switch (type) { + case "next": + socket.emit(STUDY_EMIT_EVENT.NEXT, { roomId: sessionId }); + break; + case "prev": + socket.emit(STUDY_EMIT_EVENT.INDEX, { + roomId: sessionId, + index: roomMetadata.currentIndex - 1, + }); + break; + case "current": + socket.emit(STUDY_EMIT_EVENT.CURRENT, { roomId: sessionId }); + break; + case "move": + socket.emit(STUDY_EMIT_EVENT.INDEX, { roomId: sessionId, index }); + break; + } + } + } + }; + + const startStudySession = () => { + if (socket) { + socket.emit(STUDY_EMIT_EVENT.START, { roomId: sessionId }); + } + }; + + const stopStudySession = () => { + if (socket) { + socket.emit(STUDY_EMIT_EVENT.STOP, { roomId: sessionId }); + } + }; + + return { requestChangeIndex, startStudySession, stopStudySession }; +}; + +export default useStudy; diff --git a/frontend/src/hooks/type/session.d.ts b/frontend/src/hooks/type/session.d.ts index 200d7fbf..7d4d8f33 100644 --- a/frontend/src/hooks/type/session.d.ts +++ b/frontend/src/hooks/type/session.d.ts @@ -9,6 +9,9 @@ export interface RoomMetadata { inProgress: boolean; host: UserInfo; category: string | string[]; + questionListId: number; + questionListContents: Question[]; + currentIndex: number; } export interface RoomJoinResponse { @@ -22,6 +25,16 @@ export interface RoomJoinResponse { title: string; id: string; connectionMap: { [socketId: string]: UserInfo }; + questionListId: number; + questionListContents: Question[]; + currentIndex: number; +} + +export interface Question { + id: number; + content: string; + index: number; + questionListId: number; } export interface UserInfo { @@ -48,3 +61,8 @@ export interface PeerConnection { isHost?: boolean; // 호스트 여부 reaction?: string; } + +export interface ProgressResponse { + status: "success" | "error"; + inProgress: boolean; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index c6b4bbb4..31fb23af 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -56,6 +56,12 @@ } } +.animate-progress { + animation: expand 2s ease; + animation-iteration-count: 1; + animation-timing-function: linear; +} + .revealExpand { animation: revealExpand 0.2s ease; animation-iteration-count: 1; diff --git a/frontend/src/pages/SessionPage.tsx b/frontend/src/pages/SessionPage.tsx index ca6f220a..89d395bf 100644 --- a/frontend/src/pages/SessionPage.tsx +++ b/frontend/src/pages/SessionPage.tsx @@ -1,12 +1,13 @@ import VideoContainer from "@components/session/VideoContainer.tsx"; import { useParams } from "react-router-dom"; -import SessionSidebar from "@components/session/SessionSidebar.tsx"; -import SessionToolbar from "@components/session/SessionToolbar.tsx"; +import SessionSidebar from "@components/session/Sidebar/SessionSidebar.tsx"; +import SessionToolbar from "@components/session/Toolbar/SessionToolbar.tsx"; import { useSession } from "@hooks/session/useSession"; import useSocket from "@hooks/useSocket"; import SessionHeader from "@components/session/SessionHeader"; import { useEffect } from "react"; import useToast from "@hooks/useToast.ts"; +import SidebarContainer from "@components/session/Sidebar/SidebarContainer.tsx"; const SessionPage = () => { const { sessionId } = useParams(); @@ -40,6 +41,9 @@ const SessionPage = () => { emitReaction, videoLoading, peerMediaStatus, + requestChangeIndex, + startStudySession, + stopStudySession, } = useSession(sessionId!); return ( @@ -119,6 +123,7 @@ const SessionPage = () => {
{ isVideoOn={isVideoOn} isMicOn={isMicOn} videoLoading={videoLoading} + isHost={isHost} + isInProgress={roomMetadata?.inProgress ?? false} + startStudySession={startStudySession} + stopStudySession={stopStudySession} + currentIndex={roomMetadata?.currentIndex ?? -1} + maxQuestionLength={roomMetadata?.questionListContents.length ?? 0} /> - + + + );