diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 1f701d93..070ef94d 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -36,10 +36,11 @@ export default tseslint.config({ ignores: ['.*'] }, js.configs.recommended, ...t ], 'react/react-in-jsx-scope': 'off', eqeqeq: ['error', 'always'], - indent: ['error', 2], + indent: ['error', 2, { SwitchCase: 1 }], quotes: ['error', 'single'], semi: ['error', 'always'], - '@typescript-eslint/no-unused-vars': ['error'] + '@typescript-eslint/no-unused-vars': ['error'], + '@typescript-eslint/no-explicit-any': 'off' }, settings: { ...reactRecommended.settings diff --git a/frontend/index.html b/frontend/index.html index e392aa7c..1ffedf04 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/package.json b/frontend/package.json index 4fd208f2..010df8d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,16 +15,19 @@ "msw:init": "msw init public/" }, "dependencies": { + "@tanstack/react-query": "^5.59.20", "@types/node": "^22.9.0", "@vitejs/plugin-react": "^4.3.3", - "@tanstack/react-query": "^5.59.20", "axios": "^1.7.7", "eslint-plugin-react": "^7.37.2", + "framer-motion": "^11.11.17", "hls.js": "^1.5.17", "nanoid": "^5.0.8", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.53.2", "react-router-dom": "^6.27.0", + "socket.io-client": "^4.8.1", "styled-components": "^6.1.13", "vite": "^5.4.10", "vite-plugin-svgr": "^4.3.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0519e67..cee1802a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,15 +4,18 @@ import { theme } from './styles/theme'; import { MainPage, ClientPage, HostPage } from './pages'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '@apis/index'; +import withUserId from '@hocs/withUserId'; -function App() { +function AppComponent() { return ( - + } /> } /> @@ -26,4 +29,6 @@ function App() { ); } -export default App; \ No newline at end of file +const App = withUserId(AppComponent); + +export default App; diff --git a/frontend/src/apis/fetchBroadcastStatus.ts b/frontend/src/apis/fetchBroadcastStatus.ts index 86725e15..aa0c3b85 100644 --- a/frontend/src/apis/fetchBroadcastStatus.ts +++ b/frontend/src/apis/fetchBroadcastStatus.ts @@ -1,13 +1,10 @@ -import { getSessionKey } from '@utils/streamKey'; import { fetchInstance } from '.'; export type BroadcastStatusResponse = { state: boolean; }; -export const fetchBroadcastStatus = async (): Promise => { - const sessionKey = getSessionKey(); - +export const fetchBroadcastStatus = async (sessionKey: string): Promise => { const response = await fetchInstance().get('/host/state', { params: { sessionKey diff --git a/frontend/src/apis/fetchLive.ts b/frontend/src/apis/fetchLive.ts new file mode 100644 index 00000000..f1ff0807 --- /dev/null +++ b/frontend/src/apis/fetchLive.ts @@ -0,0 +1,17 @@ +import { AxiosResponse } from 'axios'; +import { fetchInstance } from '.'; +import { ClientLive } from '@type/live'; + +type ClientLiveResponse = { + info: ClientLive; +}; + +export const fetchLive = async ({ liveId }: { liveId: string }): Promise => { + const response: AxiosResponse = await fetchInstance().get('/streams/live', { + params: { + liveId + } + }); + + return response.data.info; +}; diff --git a/frontend/src/apis/fetchMainLive.ts b/frontend/src/apis/fetchMainLive.ts new file mode 100644 index 00000000..23c4a328 --- /dev/null +++ b/frontend/src/apis/fetchMainLive.ts @@ -0,0 +1,13 @@ +import { AxiosResponse } from 'axios'; +import { fetchInstance } from '.'; +import { MainLive } from '@type/live'; + +type MainLiveResponse = { + info: MainLive[]; +}; + +export const fetchMainLive = async (): Promise => { + const response: AxiosResponse = await fetchInstance().get('/streams/random'); + + return response.data.info; +}; diff --git a/frontend/src/apis/fetchRecentLive.ts b/frontend/src/apis/fetchRecentLive.ts new file mode 100644 index 00000000..c08c63d6 --- /dev/null +++ b/frontend/src/apis/fetchRecentLive.ts @@ -0,0 +1,13 @@ +import { AxiosResponse } from 'axios'; +import { fetchInstance } from '.'; +import { RecentLive } from '@type/live'; + +type RecentLiveResponse = { + info: RecentLive[]; +}; + +export const fetchRecentLive = async (): Promise => { + const response: AxiosResponse = await fetchInstance().get('/streams/latest'); + + return response.data.info; +}; diff --git a/frontend/src/apis/queries/client/useFetchLive.ts b/frontend/src/apis/queries/client/useFetchLive.ts new file mode 100644 index 00000000..a5c7a4e4 --- /dev/null +++ b/frontend/src/apis/queries/client/useFetchLive.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchLive } from '@apis/fetchLive'; +import { ClientLive } from '@type/live'; + +export const useClientLive = ({ liveId }: { liveId: string }) => { + return useQuery({ + queryKey: ['clientLive'], + queryFn: () => fetchLive({ liveId: liveId }), + refetchOnWindowFocus: false + }); +}; diff --git a/frontend/src/apis/queries/host/useBroadcastStatusPolling.ts b/frontend/src/apis/queries/host/useBroadcastStatusPolling.ts index b6bbec6b..5ca45b2d 100644 --- a/frontend/src/apis/queries/host/useBroadcastStatusPolling.ts +++ b/frontend/src/apis/queries/host/useBroadcastStatusPolling.ts @@ -1,10 +1,10 @@ import { useQuery } from '@tanstack/react-query'; import { fetchBroadcastStatus } from '@apis/fetchBroadcastStatus'; -export const useBroadcastStatusPolling = (pollingInterVal = 10000) => { +export const useBroadcastStatusPolling = (sessionKey: string, pollingInterVal = 10000) => { return useQuery({ - queryKey: ['broadcastState'], - queryFn: fetchBroadcastStatus, + queryKey: ['broadcastState', sessionKey], + queryFn: () => fetchBroadcastStatus(sessionKey), refetchInterval: pollingInterVal, refetchIntervalInBackground: true, refetchOnWindowFocus: true, diff --git a/frontend/src/apis/queries/host/useUpdateHost.ts b/frontend/src/apis/queries/host/useUpdateHost.ts index 8a1f52b9..794bf297 100644 --- a/frontend/src/apis/queries/host/useUpdateHost.ts +++ b/frontend/src/apis/queries/host/useUpdateHost.ts @@ -1,5 +1,6 @@ -import { HostInfo, updateHost } from '@apis/updateHost'; +import { updateHost } from '@apis/updateHost'; import { useMutation, UseMutationResult } from '@tanstack/react-query'; +import { HostInfo } from '@type/hostInfo'; type Params = { onSuccess?: (data: HostInfo) => void; diff --git a/frontend/src/apis/queries/main/useFetchMainLive.ts b/frontend/src/apis/queries/main/useFetchMainLive.ts new file mode 100644 index 00000000..91c902ab --- /dev/null +++ b/frontend/src/apis/queries/main/useFetchMainLive.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchMainLive } from '@apis/fetchMainLive'; +import { MainLive } from '@type/live'; + +export const useMainLive = () => { + return useQuery({ + queryKey: ['mainLive'], + queryFn: fetchMainLive, + refetchOnWindowFocus: false + }); +}; diff --git a/frontend/src/apis/queries/main/useFetchRecentLive.ts b/frontend/src/apis/queries/main/useFetchRecentLive.ts new file mode 100644 index 00000000..4ee93444 --- /dev/null +++ b/frontend/src/apis/queries/main/useFetchRecentLive.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchRecentLive } from '@apis/fetchRecentLive'; +import { RecentLive } from '@type/live'; + +export const useRecentLive = () => { + return useQuery({ + queryKey: ['recentLive'], + queryFn: fetchRecentLive, + refetchOnWindowFocus: false + }); +}; diff --git a/frontend/src/apis/updateHost.ts b/frontend/src/apis/updateHost.ts index 65cfcd40..4bb4c350 100644 --- a/frontend/src/apis/updateHost.ts +++ b/frontend/src/apis/updateHost.ts @@ -1,13 +1,6 @@ import { AxiosResponse } from 'axios'; import { fetchInstance } from '.'; - -export interface HostInfo { - userId: string; - liveTitle: string; - defaultThumbnailImageUrl: string; - category: string; - tags: string[]; -} +import { HostInfo } from '@type/hostInfo'; export const updateHost = async (hostInfo: HostInfo): Promise => { const response: AxiosResponse = await fetchInstance().post('/host/update', hostInfo); diff --git a/frontend/src/assets/icons/speaker.svg b/frontend/src/assets/icons/speaker.svg new file mode 100644 index 00000000..8bbd8b80 --- /dev/null +++ b/frontend/src/assets/icons/speaker.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/logo.gif b/frontend/src/assets/logo.gif deleted file mode 100644 index 21f63a42..00000000 Binary files a/frontend/src/assets/logo.gif and /dev/null differ diff --git a/frontend/src/assets/player_loading.gif b/frontend/src/assets/player_loading.gif deleted file mode 100644 index a6943a56..00000000 Binary files a/frontend/src/assets/player_loading.gif and /dev/null differ diff --git a/frontend/src/assets/sampleThumbnail.png b/frontend/src/assets/sampleThumbnail.png deleted file mode 100644 index c5bf6826..00000000 Binary files a/frontend/src/assets/sampleThumbnail.png and /dev/null differ diff --git a/frontend/src/assets/sample_profile.png b/frontend/src/assets/sample_profile.png new file mode 100644 index 00000000..5101e73b Binary files /dev/null and b/frontend/src/assets/sample_profile.png differ diff --git a/frontend/src/components/chat/ChatHeader.tsx b/frontend/src/components/chat/ChatHeader.tsx index 7e711d74..306beb8e 100644 --- a/frontend/src/components/chat/ChatHeader.tsx +++ b/frontend/src/components/chat/ChatHeader.tsx @@ -1,27 +1,56 @@ import styled from 'styled-components'; import ThreePointIcon from '@assets/icons/three-point.svg'; import OutIcon from '@assets/icons/out.svg'; +import { useCallback, useContext, useEffect, useRef } from 'react'; +import LayerPopup from './LayerPopup'; +import { ChatContext } from 'src/contexts/chatContext'; interface ChatHeaderProps { outBtnHandler?: () => void; } export const ChatHeader = ({ outBtnHandler }: ChatHeaderProps) => { + const { state, dispatch } = useContext(ChatContext); + const headerRef = useRef(null); + + const toggleSettings = () => { + dispatch({ type: 'TOGGLE_SETTINGS' }); + }; + + const handleClickOutside = useCallback( + (event: MouseEvent) => { + if (headerRef.current && !headerRef.current.contains(event.target as Node)) { + dispatch({ type: 'CLOSE_SETTINGS' }); + } + }, + [dispatch] + ); + + useEffect(() => { + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, [handleClickOutside]); + return ( - +

채팅

- + + {state.isSettingsOpen && }
); }; + export default ChatHeader; const ChatHeaderContainer = styled.div` + position: relative; display: flex; justify-content: space-between; align-items: center; @@ -42,3 +71,10 @@ const StyledIcon = styled.svg` height: 25px; cursor: pointer; `; + +const PopupWrapper = styled.div` + position: absolute; + top: 60px; + right: 0; + z-index: 1000; +`; diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx index 4181d494..3671f59f 100644 --- a/frontend/src/components/chat/ChatInput.tsx +++ b/frontend/src/components/chat/ChatInput.tsx @@ -1,37 +1,101 @@ import styled, { css } from 'styled-components'; import SpeechBubbleIcon from '@assets/icons/speech-bubble.svg'; import QuestionIcon from '@assets/icons/question.svg'; +import SpeakerIcon from '@assets/icons/speaker.svg'; import SendIcon from '@assets/icons/send.svg'; -import { useRef, useEffect, useState } from 'react'; +import { useRef, useEffect, useState, ChangeEvent } from 'react'; +import { Socket } from 'socket.io-client'; +import { useParams } from 'react-router-dom'; +import { CHATTING_SOCKET_SEND_EVENT, CHATTING_TYPES } from '@constants/chat'; +import { ChattingTypes, MessageSendData } from '@type/chat'; +import { getStoredId } from '@utils/id'; +import { UserType } from '@type/user'; interface ChatInputProps { - type: 'normal' | 'question'; + socket: Socket | null; + userType: UserType; } -export const ChatInput = ({ type }: ChatInputProps) => { +const INITIAL_TEXTAREA_HEIGHT = 15; + +export const ChatInput = ({ socket, userType }: ChatInputProps) => { const [hasInput, setHasInput] = useState(false); const [isFocused, setIsFocused] = useState(false); + const [msgType, setMsgType] = useState(CHATTING_TYPES.NORMAL); + const [message, setMessage] = useState(''); const textareaRef = useRef(null); + const { id } = useParams(); + + const userId = getStoredId(); + + const handleMsgType = () => { + if (!userType) return; + + setMsgType(() => { + if (userType === 'host') { + return msgType === CHATTING_TYPES.NORMAL ? CHATTING_TYPES.NOTICE : CHATTING_TYPES.NORMAL; + } + return msgType === CHATTING_TYPES.NORMAL ? CHATTING_TYPES.QUESTION : CHATTING_TYPES.NORMAL; + }); + }; + + const handleMessageSend = () => { + if (!socket || !message.trim()) return; + + const eventMap = { + [CHATTING_TYPES.NORMAL]: CHATTING_SOCKET_SEND_EVENT.NORMAL, + [CHATTING_TYPES.QUESTION]: CHATTING_SOCKET_SEND_EVENT.QUESTION, + [CHATTING_TYPES.NOTICE]: CHATTING_SOCKET_SEND_EVENT.NOTICE + }; + + const eventName = eventMap[msgType]; + + socket.emit(eventName, { + roomId: id, + userId, + msg: message + } as MessageSendData); + + resetTextareaHeight(); + setMessage(''); + setHasInput(false); + }; + + const handleInputChange = (e: ChangeEvent) => { + setMessage(e.target.value); + setHasInput(e.target.value.length > 0); + }; + + const resetTextareaHeight = () => { + if (textareaRef.current) { + textareaRef.current.style.height = `${INITIAL_TEXTAREA_HEIGHT}px`; + } + }; + useEffect(() => { + const textarea = textareaRef.current; + const handleResize = () => { - if (textareaRef.current) { - textareaRef.current.style.height = '14px'; - textareaRef.current.style.height = `${textareaRef.current.scrollHeight - 5}px`; + if (textarea) { + requestAnimationFrame(() => { + textarea.style.height = `${INITIAL_TEXTAREA_HEIGHT}px`; + textarea.style.height = `${textarea.scrollHeight - 5}px`; + }); } }; - if (textareaRef.current) { - textareaRef.current.addEventListener('input', handleResize); + if (textarea) { + textarea.addEventListener('input', handleResize); handleResize(); } return () => { - if (textareaRef.current) { - textareaRef.current.removeEventListener('input', handleResize); + if (textarea) { + textarea.removeEventListener('input', handleResize); } }; - }, []); + }, [textareaRef.current]); const handleBlur = () => { if (textareaRef.current) { @@ -44,29 +108,52 @@ export const ChatInput = ({ type }: ChatInputProps) => { setIsFocused(true); }; + const getButtonIcon = () => { + switch (msgType) { + case CHATTING_TYPES.NORMAL: { + return ; + } + case CHATTING_TYPES.QUESTION: { + return ; + } + case CHATTING_TYPES.NOTICE: { + return ; + } + default: { + return ; + } + } + }; + return ( - - {type === 'normal' ? : } + + {getButtonIcon()} + - + ); }; + export default ChatInput; const ChatInputWrapper = styled.div<{ $hasInput: boolean; $isFocused: boolean }>` min-height: 20px; display: flex; - padding: 5px 10px; + padding: 7px 10px 5px 10px; gap: 10px; border: 3px solid ${({ theme }) => theme.tokenColors['text-weak']}; border-radius: 7px; @@ -83,14 +170,16 @@ const ChatInputWrapper = styled.div<{ $hasInput: boolean; $isFocused: boolean }> const ChatInputArea = styled.textarea` width: 100%; - max-height: 40px; - overflow-y: auto; + max-height: 65px; + scrollbar-width: none; resize: none; border: none; outline: none; color: ${({ theme }) => theme.tokenColors['text-strong']}; ${({ theme }) => theme.tokenTypographys['display-medium16']} - background-color: transparent; + background-color:transparent; + white-space: normal; + line-height: 20px; `; const InputBtn = styled.button` @@ -99,6 +188,9 @@ const InputBtn = styled.button` align-items: center; color: ${({ theme }) => theme.tokenColors['text-strong']}; cursor: pointer; + :hover { + color: ${({ theme }) => theme.tokenColors['brand-default']}; + } `; const StyledIcon = styled.svg` diff --git a/frontend/src/components/chat/ChatList.tsx b/frontend/src/components/chat/ChatList.tsx index 68c2c1a8..57d08dfb 100644 --- a/frontend/src/components/chat/ChatList.tsx +++ b/frontend/src/components/chat/ChatList.tsx @@ -1,63 +1,78 @@ import styled from 'styled-components'; import QuestionCard from './QuestionCard'; -import { useEffect, useRef } from 'react'; +import { useContext, useEffect, useRef } from 'react'; +import { MessageReceiveData } from '@type/chat'; +import { CHATTING_TYPES } from '@constants/chat'; +import { ChatContext } from 'src/contexts/chatContext'; +import NoticeCard from './NoticeCard'; -const sampleData = [ - { user: '고양이', message: 'ㅇㅅㅇ', type: 'normal' }, - { user: '강아지', message: 'ㅎㅇㅎㅇ', type: 'normal' }, - { - user: '오리', - message: - '가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다라마바사아자차카타파하가나다라마바사아자차카타파하', - type: 'normal' - } -]; - -function getRandomBrightColor(): string { - const hue = Math.floor(Math.random() * 360); - const saturation = Math.floor(Math.random() * 50) + 50; - const lightness = Math.floor(Math.random() * 30) + 50; - - return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +export interface ChatListProps { + messages: MessageReceiveData[]; + userId: string | undefined; } -export const ChatList = () => { +export const ChatList = ({ messages, userId }: ChatListProps) => { + const { state } = useContext(ChatContext); const chatListRef = useRef(null); useEffect(() => { if (chatListRef.current) { chatListRef.current.scrollTop = chatListRef.current.scrollHeight; } - }, []); + }, [messages]); return ( - - {[...Array(6)].map((_, i) => - sampleData.map((chat, index) => ( - - {chat.type === 'normal' ? ( - - {chat.user} - {chat.message} - + + + {messages.map((chat, index) => ( + + {chat.msgType === CHATTING_TYPES.QUESTION ? ( + + ) : chat.msgType === CHATTING_TYPES.NOTICE ? ( + + 📢 + {chat.msg} + ) : ( - + + {userId === chat.userId && 🧀} + {chat.nickname} + {chat.msg} + )} - )) + ))} + + {state.isNoticePopupOpen && ( + + + )} - + ); }; + export default ChatList; +const ChatListSection = styled.div` + display: flex; + flex-direction: column; + justify-content: end; + position: relative; + height: 100%; +`; + const ChatListWrapper = styled.div` + box-sizing: border-box; + position: absolute; max-height: 100%; + width: 100%; display: flex; flex-direction: column; - overflow-y: auto; padding: 50px 20px 0 20px; + overflow-y: auto; scrollbar-width: none; + z-index: 100; `; const ChatItemWrapper = styled.div` @@ -65,6 +80,17 @@ const ChatItemWrapper = styled.div` padding: 5px 0; `; +const NoticeChat = styled.div` + display: flex; + padding: 10px 15px; + gap: 10px; + ${({ theme }) => theme.tokenTypographys['display-medium12']}; + color: ${({ theme }) => theme.tokenColors['text-default']}; + background-color: #0e0f10; + overflow-wrap: break-word; + word-break: break-word; +`; + const NormalChat = styled.div<{ $pointColor: string }>` ${({ theme }) => theme.tokenTypographys['display-medium14']}; color: ${({ theme }) => theme.tokenColors['color-white']}; @@ -72,4 +98,14 @@ const NormalChat = styled.div<{ $pointColor: string }>` color: ${({ $pointColor }) => $pointColor}; margin-right: 5px; } + overflow-wrap: break-word; + word-break: break-word; +`; + +const PopupWrapper = styled.div` + position: absolute; + bottom: 0; + left: 5%; + right: 5%; + z-index: 1000; `; diff --git a/frontend/src/components/chat/ChatQuestionSection.tsx b/frontend/src/components/chat/ChatQuestionSection.tsx index 7e876abc..cf87494f 100644 --- a/frontend/src/components/chat/ChatQuestionSection.tsx +++ b/frontend/src/components/chat/ChatQuestionSection.tsx @@ -1,45 +1,99 @@ import { useState } from 'react'; import styled from 'styled-components'; import QuestionCard from './QuestionCard'; +import { MessageReceiveData, MessageSendData } from '@type/chat'; +import { Socket } from 'socket.io-client'; +import { CHATTING_SOCKET_SEND_EVENT } from '@constants/chat'; +import { useParams } from 'react-router-dom'; +import { getStoredId } from '@utils/id'; +import { UserType } from '@type/user'; -export const ChatQuestionSection = () => { +export interface ChatQuestionSectionProps { + questions: MessageReceiveData[]; + socket: Socket | null; + userType: UserType; +} + +export const ChatQuestionSection = ({ questions, socket, userType }: ChatQuestionSectionProps) => { const [expanded, setExpanded] = useState(false); + const { id } = useParams(); + + const userId = getStoredId(); + const toggleSection = () => { setExpanded((prev) => !prev); }; + const handleQuestionDone = (questionId: number) => { + if (!socket) return; + + socket.emit(CHATTING_SOCKET_SEND_EVENT.QUESTION_DONE, { + roomId: id, + userId, + questionId + } as MessageSendData); + }; + return ( - - - {expanded && } - - + + + {questions.length === 0 ? ( + 아직 질문이 없어요 + ) : ( + <> + + {expanded && + questions + .slice(1) + .map((question) => ( + + ))} + + + )} + + ); }; export default ChatQuestionSection; +const SectionWrapper = styled.div` + position: relative; + display: flex; + flex-direction: column; +`; + const SectionContainer = styled.div` display: flex; flex-direction: column; - justify-content: center; - min-height: 95px; - padding: 13px 20px 0px 20px; + min-height: 25px; + max-height: 300px; + overflow-y: scroll; + padding: 13px 20px 25px 20px; gap: 10px; border-top: 1px solid ${({ theme }) => theme.tokenColors['surface-alt']}; border-bottom: 1px solid ${({ theme }) => theme.tokenColors['surface-alt']}; - overflow: hidden; `; const SwipeBtn = styled.button` - position: relative; + position: absolute; + bottom: 0; + left: 0; width: 100%; height: 25px; cursor: pointer; + background-color: ${({ theme }) => theme.tokenColors['surface-default']}; &::before { content: ''; @@ -53,3 +107,10 @@ const SwipeBtn = styled.button` transform: translate(-50%, -50%); } `; + +const NoQuestionMessage = styled.div` + text-align: center; + ${({ theme }) => theme.tokenTypographys['display-medium14']}; + color: ${({ theme }) => theme.tokenColors['text-weak']}; + padding: 20px 0; +`; diff --git a/frontend/src/components/chat/ChatRoom.tsx b/frontend/src/components/chat/ChatRoom.tsx index 7b26584f..027530af 100644 --- a/frontend/src/components/chat/ChatRoom.tsx +++ b/frontend/src/components/chat/ChatRoom.tsx @@ -3,28 +3,102 @@ import ChatHeader from './ChatHeader'; import ChatInput from './ChatInput'; import ChatList from './ChatList'; import ChatQuestionSection from './ChatQuestionSection'; +import { Socket } from 'socket.io-client'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { createSocket } from '@utils/createSocket'; +import { CHATTING_SOCKET_DEFAULT_EVENT, CHATTING_SOCKET_RECEIVE_EVENT } from '@constants/chat'; +import { ChatInitData, MessageReceiveData } from '@type/chat'; +import { ChatProvider } from 'src/contexts/chatContext'; +import { getStoredId } from '@utils/id'; +import { UserType } from '@type/user'; + +const TEST_SOCKET_URL = 'http://192.168.10.18:4000'; + +interface ChatRoomProps { + userType: UserType; +} + +export const ChatRoom = ({ userType }: ChatRoomProps) => { + const [socket, setSocket] = useState(null); + const [messages, setMessages] = useState([]); + const [questions, setQuestions] = useState([]); + const [isChatRoomVisible, setIsChatRoomVisible] = useState(true); + + const { id } = useParams(); + + const userId = getStoredId(); + + useEffect(() => { + setMessages([]); + + const eventMap = { + [CHATTING_SOCKET_RECEIVE_EVENT.INIT]: (initData: ChatInitData) => { + setQuestions(initData.questionList); + }, + [CHATTING_SOCKET_RECEIVE_EVENT.NORMAL]: (newMessage: MessageReceiveData) => { + setMessages((prevMessages) => [...prevMessages, newMessage]); + }, + [CHATTING_SOCKET_RECEIVE_EVENT.QUESTION]: (questionMessage: MessageReceiveData) => { + setMessages((prevMessages) => [...prevMessages, questionMessage]); + setQuestions((prevMessages) => [questionMessage, ...prevMessages]); + }, + [CHATTING_SOCKET_RECEIVE_EVENT.NOTICE]: (noticeMessage: MessageReceiveData) => { + setMessages((prevMessages) => [...prevMessages, noticeMessage]); + }, + [CHATTING_SOCKET_RECEIVE_EVENT.QUESTION_DONE]: (questionMessage: MessageReceiveData) => { + setQuestions((prevMessages) => + prevMessages.filter((message) => message.questionId !== questionMessage.questionId) + ); + } + }; + + const newSocket = createSocket(TEST_SOCKET_URL, eventMap, (socket) => { + socket.emit(CHATTING_SOCKET_DEFAULT_EVENT.JOIN_ROOM, { roomId: id, userId }); + }); + + setSocket(newSocket); + + return () => { + if (newSocket) { + newSocket.disconnect(); + } + }; + }, [id, userId]); -export const ChatRoom = () => { return ( - - + + {/* 임시 버튼입니다 */} + setIsChatRoomVisible(true)}> + 채팅 보기 + + + + setIsChatRoomVisible(false)} /> - + - - - + + + - - - - + + + + + ); }; export default ChatRoom; -const ChatRoomContainer = styled.aside` - display: flex; +const ChatOpenBtn = styled.button<{ $isVisible: boolean }>` + display: ${({ $isVisible }) => ($isVisible ? 'flex' : 'none')}; + height: 15px; + background-color: #505050; +`; + +const ChatRoomContainer = styled.aside<{ $isVisible: boolean }>` + display: ${({ $isVisible }) => ($isVisible ? 'flex' : 'none')}; flex-direction: column; height: 100%; width: 380px; diff --git a/frontend/src/components/chat/LayerPopup.tsx b/frontend/src/components/chat/LayerPopup.tsx index 01c2ef92..186c7a10 100644 --- a/frontend/src/components/chat/LayerPopup.tsx +++ b/frontend/src/components/chat/LayerPopup.tsx @@ -1,10 +1,19 @@ +import { useContext } from 'react'; +import { ChatContext } from 'src/contexts/chatContext'; import styled from 'styled-components'; export const LayerPopup = () => { + const { dispatch } = useContext(ChatContext); + + const openSetting = (option: 'chat_notice' | 'ai_summary' | null) => { + dispatch({ type: 'SET_SETTING', payload: option }); + }; + return ( - 📢 채팅 규칙 + openSetting('chat_notice')}>📢 채팅 규칙 + openSetting('ai_summary')}>🤖 AI 요약 (준비 중) ); @@ -13,9 +22,9 @@ export default LayerPopup; const LayerPopupContainer = styled.div` width: 262px; - background-color: #373a3f; + background-color: #24272b; border-radius: 7px; - box-shadow: 0px 4px 4px 0px #3c444b3c; + box-shadow: 0px 4px 4px 0px #0d0d0da2; padding: 5px; gap: 1px; ${({ theme }) => theme.tokenTypographys['display-bold14']} @@ -24,7 +33,7 @@ const LayerPopupContainer = styled.div` const LayerPopupWrapper = styled.div` :hover { - background-color: #5e5e61; + background-color: #4343459f; } `; diff --git a/frontend/src/components/chat/NoticeCard.tsx b/frontend/src/components/chat/NoticeCard.tsx index af669c39..4ff86478 100644 --- a/frontend/src/components/chat/NoticeCard.tsx +++ b/frontend/src/components/chat/NoticeCard.tsx @@ -1,7 +1,15 @@ import styled from 'styled-components'; import CloseIcon from '@assets/icons/close.svg'; +import { useContext } from 'react'; +import { ChatContext } from 'src/contexts/chatContext'; export const NoticeCard = () => { + const { dispatch } = useContext(ChatContext); + + const toggleSettings = () => { + dispatch({ type: 'TOGGLE_ANNOUNCEMENT_POPUP' }); + }; + return ( @@ -15,7 +23,7 @@ export const NoticeCard = () => {
컨퍼런스 공지 📢
- +
@@ -38,8 +46,8 @@ const NoticeCardContainer = styled.div` padding: 20px; gap: 13px; border-radius: 7px; - box-shadow: 0px 4px 4px 0px #3c444b3c; - background-color: #373a3f; + box-shadow: 0px 4px 4px 0px #0d0d0da2; + background-color: #202224; color: ${({ theme }) => theme.tokenColors['color-white']}; `; diff --git a/frontend/src/components/chat/QuestionCard.tsx b/frontend/src/components/chat/QuestionCard.tsx index ee881e4f..d5c3001f 100644 --- a/frontend/src/components/chat/QuestionCard.tsx +++ b/frontend/src/components/chat/QuestionCard.tsx @@ -1,28 +1,29 @@ import styled from 'styled-components'; import CheckIcon from '@assets/icons/check.svg'; +import { MessageReceiveData } from '@type/chat'; interface QuestionCardProps { type: 'host' | 'client'; - user: string; - message: string; + question: MessageReceiveData; + handleQuestionDone?: (questionId: number) => void; } -export const QuestionCard = ({ type, user, message }: QuestionCardProps) => { +export const QuestionCard = ({ type, question, handleQuestionDone }: QuestionCardProps) => { return ( - 💟 {user} + 💟 {question.nickname} n분전 - {type === 'host' && ( - + {type === 'host' && handleQuestionDone && ( + handleQuestionDone(question.questionId as number)}> )} - {message} + {question.msg} ); }; diff --git a/frontend/src/components/client/ClientView.tsx b/frontend/src/components/client/ClientView.tsx index 89a31ff0..2f402d14 100644 --- a/frontend/src/components/client/ClientView.tsx +++ b/frontend/src/components/client/ClientView.tsx @@ -1,15 +1,24 @@ import styled from 'styled-components'; +import { useParams } from 'react-router-dom'; +import { useClientLive } from '@apis/queries/client/useFetchLive'; import Player from './Player'; import PlayerInfo from './PlayerInfo'; -import Footer from './Footer'; +import Footer from '@components/common/Footer'; const ClientView = () => { + const { id: liveId } = useParams(); + const { data: clientLiveData } = useClientLive({ liveId: liveId as string }); + + if (!clientLiveData) { + return
로딩 중...
; + } + return (

클라이언트 페이지

- - + +