diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 387d9a93..f277c59a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,10 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { ThemeProvider } from 'styled-components'; import { theme } from './styles/theme'; -import { MainPage, ClientPage, HostPage } from './pages'; +import { ClientPage, ErrorPage, HostPage, MainPage, ReplayPage } from './pages'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '@apis/index'; import withUserId from '@hocs/withUserId'; -import ReplayPage from '@pages/ReplayPage'; function AppComponent() { return ( @@ -19,12 +18,10 @@ function AppComponent() { > } /> - } /> } /> - } /> } /> } /> - } /> + } /> diff --git a/frontend/src/apis/checkLiveExist.ts b/frontend/src/apis/checkLiveExist.ts new file mode 100644 index 00000000..99de0460 --- /dev/null +++ b/frontend/src/apis/checkLiveExist.ts @@ -0,0 +1,13 @@ +import { AxiosResponse } from 'axios'; +import { fetchInstance } from '.'; +import { LiveExistenceResponse } from '@type/live'; + +export const checkLiveExist = async ({ liveId }: { liveId: string }): Promise => { + const response: AxiosResponse = await fetchInstance().get('/streams/existence', { + params: { + sessionKey: liveId + } + }); + + return response.data; +}; diff --git a/frontend/src/apis/checkReplayExist.ts b/frontend/src/apis/checkReplayExist.ts new file mode 100644 index 00000000..ffcac144 --- /dev/null +++ b/frontend/src/apis/checkReplayExist.ts @@ -0,0 +1,13 @@ +import { AxiosResponse } from 'axios'; +import { fetchInstance } from '.'; +import { ReplayExistenceResponse } from '@type/replay'; + +export const checkReplayExist = async ({ videoId }: { videoId: string }): Promise => { + const response: AxiosResponse = await fetchInstance().get('/replay/existence', { + params: { + videoId + } + }); + + return response.data; +}; diff --git a/frontend/src/apis/fetchLive.ts b/frontend/src/apis/fetchLive.ts index f1ff0807..102c5501 100644 --- a/frontend/src/apis/fetchLive.ts +++ b/frontend/src/apis/fetchLive.ts @@ -1,17 +1,18 @@ import { AxiosResponse } from 'axios'; import { fetchInstance } from '.'; -import { ClientLive } from '@type/live'; +import { ClientLiveResponse } 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 +export const fetchLive = async ({ liveId }: { liveId: string }): Promise => { + try { + const response: AxiosResponse = await fetchInstance().get('/streams/live', { + params: { liveId } + }); + return response.data; + } catch (error: any) { + if (error.response && error.response.status === 400) { + console.log('error', error); + throw error; } - }); - - return response.data.info; + throw error; + } }; diff --git a/frontend/src/apis/fetchReplay.ts b/frontend/src/apis/fetchReplay.ts index 834518af..01c80742 100644 --- a/frontend/src/apis/fetchReplay.ts +++ b/frontend/src/apis/fetchReplay.ts @@ -1,17 +1,20 @@ import { AxiosResponse } from 'axios'; import { fetchInstance } from '.'; -import { ReplayStream } from '@type/replay'; +import { ClientReplayResponse } from '@type/replay'; -type ReplayStreamResponse = { - info: ReplayStream; -}; - -export const fetchReplay = async ({ videoId }: { videoId: string }): Promise => { - const response: AxiosResponse = await fetchInstance().get('/replay/video', { - params: { - videoId +export const fetchReplay = async ({ videoId }: { videoId: string }): Promise => { + try { + const response: AxiosResponse = await fetchInstance().get('/replay/video', { + params: { + videoId + } + }); + return response.data; + } catch (error: any) { + if (error.response && error.response.status === 400) { + console.log('error', error); + throw error; } - }); - - return response.data.info; + throw error; + } }; diff --git a/frontend/src/apis/queries/client/useCheckLiveExist.ts b/frontend/src/apis/queries/client/useCheckLiveExist.ts new file mode 100644 index 00000000..75048221 --- /dev/null +++ b/frontend/src/apis/queries/client/useCheckLiveExist.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; + +import { checkLiveExist } from '@apis/checkLiveExist'; +import { LiveExistenceResponse } from '@type/live'; + +export const useCheckLiveExist = ({ liveId }: { liveId: string }) => { + return useQuery({ + queryKey: ['checkLiveExist'], + queryFn: () => checkLiveExist({ liveId }), + refetchOnWindowFocus: false, + initialData: { existed: true } + }); +}; diff --git a/frontend/src/apis/queries/client/useFetchLive.ts b/frontend/src/apis/queries/client/useFetchLive.ts index a5c7a4e4..38e22ab4 100644 --- a/frontend/src/apis/queries/client/useFetchLive.ts +++ b/frontend/src/apis/queries/client/useFetchLive.ts @@ -1,12 +1,14 @@ import { useQuery } from '@tanstack/react-query'; - import { fetchLive } from '@apis/fetchLive'; -import { ClientLive } from '@type/live'; +import { ClientLive, ClientLiveResponse } from '@type/live'; export const useClientLive = ({ liveId }: { liveId: string }) => { - return useQuery({ + return useQuery({ queryKey: ['clientLive'], - queryFn: () => fetchLive({ liveId: liveId }), - refetchOnWindowFocus: false + queryFn: () => fetchLive({ liveId }), + refetchOnWindowFocus: false, + initialData: { info: {} as ClientLive }, + throwOnError: true, + retry: 0, }); }; diff --git a/frontend/src/apis/queries/replay/useCheckReplayExist.ts b/frontend/src/apis/queries/replay/useCheckReplayExist.ts new file mode 100644 index 00000000..12a03e5e --- /dev/null +++ b/frontend/src/apis/queries/replay/useCheckReplayExist.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; + +import { checkReplayExist } from '@apis/checkReplayExist'; +import { ReplayExistenceResponse } from '@type/replay'; + +export const useCheckReplayExist = ({ videoId }: { videoId: string }) => { + return useQuery({ + queryKey: ['checkReplayExist'], + queryFn: () => checkReplayExist({ videoId }), + refetchOnWindowFocus: false, + initialData: { existed: true } + }); +}; diff --git a/frontend/src/apis/queries/replay/useFetchReplay.ts b/frontend/src/apis/queries/replay/useFetchReplay.ts index 24d89d32..d84143a4 100644 --- a/frontend/src/apis/queries/replay/useFetchReplay.ts +++ b/frontend/src/apis/queries/replay/useFetchReplay.ts @@ -1,12 +1,15 @@ import { useQuery } from '@tanstack/react-query'; import { fetchReplay } from '@apis/fetchReplay'; -import { ReplayStream } from '@type/replay'; +import { ClientReplayResponse, ReplayStream } from '@type/replay'; export const useClientReplay = ({ videoId }: { videoId: string }) => { - return useQuery({ + return useQuery({ queryKey: ['clientReplay'], - queryFn: () => fetchReplay({ videoId: videoId }), - refetchOnWindowFocus: false + queryFn: () => fetchReplay({ videoId }), + refetchOnWindowFocus: false, + initialData: { info: {} as ReplayStream }, + throwOnError: true, + retry: 0 }); }; diff --git a/frontend/src/assets/icons/user-block.svg b/frontend/src/assets/icons/user-block.svg new file mode 100644 index 00000000..a9e56a1e --- /dev/null +++ b/frontend/src/assets/icons/user-block.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/icons/warning_icon.svg b/frontend/src/assets/icons/warning_icon.svg new file mode 100644 index 00000000..5ed19bd8 --- /dev/null +++ b/frontend/src/assets/icons/warning_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx index ca30396f..1f1a6c49 100644 --- a/frontend/src/components/chat/ChatInput.tsx +++ b/frontend/src/components/chat/ChatInput.tsx @@ -5,7 +5,7 @@ import SpeakerIcon from '@assets/icons/speaker.svg'; import SendIcon from '@assets/icons/send.svg'; import { useRef, useEffect, useState, ChangeEvent, KeyboardEvent, memo } from 'react'; import { CHATTING_SOCKET_SEND_EVENT, CHATTING_TYPES } from '@constants/chat'; -import { ChattingTypes } from '@type/chat'; +import { ChattingSendTypes } from '@type/chat'; import { getStoredId } from '@utils/id'; import { UserType } from '@type/user'; @@ -20,7 +20,7 @@ const INITIAL_TEXTAREA_HEIGHT = 20; export const ChatInput = ({ worker, userType, roomId }: ChatInputProps) => { const [hasInput, setHasInput] = useState(false); const [isFocused, setIsFocused] = useState(false); - const [msgType, setMsgType] = useState(CHATTING_TYPES.NORMAL); + const [msgType, setMsgType] = useState(CHATTING_TYPES.NORMAL); const [message, setMessage] = useState(''); const textareaRef = useRef(null); diff --git a/frontend/src/components/chat/ChatList.tsx b/frontend/src/components/chat/ChatList.tsx index f44bed52..01af0262 100644 --- a/frontend/src/components/chat/ChatList.tsx +++ b/frontend/src/components/chat/ChatList.tsx @@ -1,59 +1,84 @@ import styled from 'styled-components'; import QuestionCard from './QuestionCard'; -import { memo, useContext, useEffect, useRef, useState } from 'react'; -import { MessageReceiveData } from '@type/chat'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { UserInfoData, MessageReceiveData } from '@type/chat'; import { CHATTING_TYPES } from '@constants/chat'; -import { ChatContext } from 'src/contexts/chatContext'; -import NoticeCard from './NoticeCard'; import ChatAutoScroll from './ChatAutoScroll'; import HostIconGreen from '@assets/icons/host_icon_green.svg'; +import { useChat } from '@contexts/chatContext'; export interface ChatListProps { messages: MessageReceiveData[]; } -const ChatItemWrapper = memo(({ chat }: { chat: MessageReceiveData }) => { - if (chat.msgType === CHATTING_TYPES.QUESTION) { - return ( - - - - ); - } else if (chat.msgType === CHATTING_TYPES.NOTICE) { - return ( - - - ๐Ÿ“ข - {chat.msg} - - - ); - } else { - return ( - - - {chat.owner === 'me' ? ( - ๐Ÿง€ - ) : chat.owner === 'host' ? ( - - ) : null} - {chat.nickname} - {chat.msg} - - - ); +const ChatItemWrapper = memo( + ({ chat, onNicknameClick }: { chat: MessageReceiveData; onNicknameClick: (data: UserInfoData) => void }) => { + const { nickname, socketId, entryTime, owner } = chat; + const handleNicknameClick = () => onNicknameClick({ nickname, socketId, entryTime, owner }); + if (chat.msgType === CHATTING_TYPES.QUESTION) { + return ( + + + + ); + } else if (chat.msgType === CHATTING_TYPES.NOTICE) { + return ( + + + ๐Ÿ“ข + {chat.msg} + + + ); + } else if (chat.msgType === CHATTING_TYPES.EXCEPTION) { + return ( + + + ๐Ÿšจ + {chat.msg} + + + ); + } else { + return ( + + + {chat.owner === 'me' ? ( + ๐Ÿง€ + ) : chat.owner === 'host' ? ( + + ) : null} + + {chat.nickname} + + {chat.msg} + + + ); + } } -}); +); ChatItemWrapper.displayName = 'ChatItemWrapper'; const ChatList = ({ messages }: ChatListProps) => { - const { state } = useContext(ChatContext); const [isAtBottom, setIsAtBottom] = useState(true); const [currentChat, setCurrentChat] = useState(null); const chatListRef = useRef(null); + const { dispatch } = useChat(); + + const onNicknameClick = useCallback( + (data: UserInfoData) => { + dispatch({ + type: 'SET_SELECTED_USER', + payload: data + }); + }, + [dispatch] + ); + const checkIfAtBottom = () => { if (!chatListRef.current) return; const { scrollTop, scrollHeight, clientHeight } = chatListRef.current; @@ -83,15 +108,10 @@ const ChatList = ({ messages }: ChatListProps) => { {messages.map((chat, index) => ( - + ))} - {state.isNoticePopupOpen && ( - - - - )} ); }; @@ -99,24 +119,23 @@ const ChatList = ({ messages }: ChatListProps) => { export default ChatList; const ChatListSection = styled.div` + position: relative; display: flex; flex-direction: column; justify-content: flex-end; - position: relative; height: 100%; + overflow-y: hidden; `; const ChatListWrapper = styled.div` box-sizing: border-box; - position: absolute; max-height: 100%; width: 100%; display: flex; flex-direction: column; - padding: 50px 20px 0 20px; overflow-y: auto; + padding: 50px 20px 0 20px; scrollbar-width: none; - z-index: 100; `; const ChatItem = styled.div` @@ -144,6 +163,7 @@ const NormalChat = styled.div<{ $isHost: boolean; $pointColor: string }>` ${({ theme }) => theme.tokenTypographys['display-bold14']}; color: ${({ $pointColor }) => $pointColor}; margin-right: 8px; + cursor: pointer; } .chat_message { @@ -155,14 +175,6 @@ const NormalChat = styled.div<{ $isHost: boolean; $pointColor: string }>` word-break: break-word; `; -const PopupWrapper = styled.div` - position: absolute; - bottom: 0; - left: 5%; - right: 5%; - z-index: 1000; -`; - const StyledIcon = styled.svg` width: 18px; height: 18px; diff --git a/frontend/src/components/chat/ChatRoomLayout.tsx b/frontend/src/components/chat/ChatRoomLayout.tsx index 0cda7c10..7ff41ee2 100644 --- a/frontend/src/components/chat/ChatRoomLayout.tsx +++ b/frontend/src/components/chat/ChatRoomLayout.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useState } from 'react'; +import { memo, useCallback, useContext, useState } from 'react'; import styled from 'styled-components'; import ChatHeader from './ChatHeader'; @@ -9,6 +9,9 @@ import ChatIcon from '@assets/icons/chat_icon.svg'; import { useChatRoom } from '@hooks/useChatRoom'; import { UserType } from '@type/user'; import { getStoredId } from '@utils/id'; +import NoticeCard from './NoticeCard'; +import { ChatContext } from '@contexts/chatContext'; +import UserInfoCard from './UserInfoCard'; interface ChatRoomLayoutProps { userType: UserType; @@ -21,6 +24,8 @@ const ChatRoomLayout = ({ userType, roomId }: ChatRoomLayoutProps) => { const userId = getStoredId(); const { worker, messages, questions } = useChatRoom(roomId as string, userId); + const { state } = useContext(ChatContext); + const handleCloseChatRoom = useCallback(() => { setIsChatRoomVisible(false); }, []); @@ -42,6 +47,18 @@ const ChatRoomLayout = ({ userType, roomId }: ChatRoomLayoutProps) => { + {state.isNoticePopupOpen && ( + + + + )} + + {state.isUserInfoPopupOpen && ( + + + + )} + @@ -68,6 +85,7 @@ const StyledChatIcon = styled(ChatIcon)` `; const ChatRoomContainer = styled.aside<{ $isVisible: boolean }>` + position: relative; display: ${({ $isVisible }) => ($isVisible ? 'flex' : 'none')}; flex-direction: column; height: 100%; @@ -80,3 +98,11 @@ const ChatRoomContainer = styled.aside<{ $isVisible: boolean }>` const ChatInputContainer = styled.div` padding: 10px 20px; `; + +const PopupWrapper = styled.div` + position: absolute; + bottom: 60px; + left: 5%; + right: 5%; + z-index: 1000; +`; diff --git a/frontend/src/components/chat/UserInfoCard.tsx b/frontend/src/components/chat/UserInfoCard.tsx new file mode 100644 index 00000000..2d06286a --- /dev/null +++ b/frontend/src/components/chat/UserInfoCard.tsx @@ -0,0 +1,162 @@ +import styled from 'styled-components'; +import CloseIcon from '@assets/icons/close.svg'; +import UserBlockIcon from '@assets/icons/user-block.svg'; +import { useChat } from 'src/contexts/chatContext'; +import { CHATTING_SOCKET_DEFAULT_EVENT } from '@constants/chat'; +import { getStoredId } from '@utils/id'; +import { UserType } from '@type/user'; +import { parseDate } from '@utils/parseDate'; +import { memo } from 'react'; +import { usePortal } from '@hooks/usePortal'; +import { useModal } from '@hooks/useModal'; +import ConfirmModal from '@components/common/ConfirmModal'; + +interface UserInfoCardProps { + worker: MessagePort | null; + roomId: string; + userType: UserType; +} + +export const UserInfoCard = ({ worker, roomId, userType }: UserInfoCardProps) => { + const { state, dispatch } = useChat(); + const { isOpen, closeModal, openModal } = useModal(); + const createPortal = usePortal(); + + const toggleSettings = () => { + dispatch({ type: 'CLOSE_USER_INFO_POPUP' }); + }; + + const { selectedUser } = state; + + const userId = getStoredId(); + + const onBan = () => { + if (!worker) return; + + worker.postMessage({ + type: CHATTING_SOCKET_DEFAULT_EVENT.BAN_USER, + payload: { + socketId: selectedUser?.socketId, + userId, + roomId + } + }); + + toggleSettings(); + }; + + return ( + + + + +
+ + {selectedUser?.owner === 'host' && '[ํ˜ธ์ŠคํŠธ] '} + {selectedUser?.nickname} + + ๋‹˜ +
+
{parseDate(selectedUser?.entryTime as string)} ์ž…์žฅ
+
+
+ + + +
+ {userType === 'host' && selectedUser?.owner === 'user' && ( + <> + + + ์‚ฌ์šฉ์ž ์ฐจ๋‹จ + + + )} + {isOpen && + createPortal( + + )} +
+ ); +}; +export default memo(UserInfoCard); + +const UserInfoCardContainer = styled.div` + display: flex; + flex-direction: column; + padding: 20px; + gap: 13px; + border-radius: 7px; + box-shadow: 0px 4px 4px 0px #0d0d0da2; + background-color: #202224; + color: ${({ theme }) => theme.tokenColors['color-white']}; +`; + +const UserInfoCardHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: start; +`; + +const UserInfoCardWrapper = styled.div` + display: flex; + align-items: center; +`; + +const UserInfoCardArea = styled.div` + display: flex; + flex-direction: column; + margin-top: 5px; + .text_info { + ${({ theme }) => theme.tokenTypographys['display-bold16']} + color: ${({ theme }) => theme.tokenColors['text-strong']}; + } + .text_point { + color: ${({ theme }) => theme.tokenColors['brand-default']}; + } + .entry_time { + ${({ theme }) => theme.tokenTypographys['display-medium12']} + color: ${({ theme }) => theme.tokenColors['color-white']}; + } +`; + +const CloseBtn = styled.button` + color: ${({ theme }) => theme.tokenColors['text-strong']}; + :hover { + color: ${({ theme }) => theme.tokenColors['brand-default']}; + } +`; + +const StyledCloseIcon = styled(CloseIcon)` + width: 30px; + height: 30px; + cursor: pointer; +`; + +const StyledUserBlockIcon = styled(UserBlockIcon)` + width: 20px; + height: 20px; +`; + +const BanBtn = styled.button` + &:hover { + background-color: #313131; + } + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + gap: 3px; + border-radius: 7px; + background-color: #101010; + color: ${({ theme }) => theme.tokenColors['text-bold']}; + ${({ theme }) => theme.tokenTypographys['display-bold14']}; + cursor: pointer; +`; diff --git a/frontend/src/components/client/Chat.tsx b/frontend/src/components/client/Chat.tsx deleted file mode 100644 index 3d950ddc..00000000 --- a/frontend/src/components/client/Chat.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useState } from 'react'; -import styled from 'styled-components'; - -const Chat = () => { - const [chatVisible, setChatVisible] = useState(true); - - const handleChatVisible = () => { - setChatVisible(!chatVisible); - }; - - return ( - <> - {chatVisible && ( - -

์ฑ„ํŒ…

- -
- )} - - ); -}; - -export default Chat; - -const ChatContainer = styled.section` - min-width: 350px; - border: 1px solid red; - padding: 10px 20px; - background-color: ${({ theme }) => theme.tokenColors['color-white']}; -`; diff --git a/frontend/src/components/client/ClientView.tsx b/frontend/src/components/client/ClientView.tsx index 86f74a45..447eb84f 100644 --- a/frontend/src/components/client/ClientView.tsx +++ b/frontend/src/components/client/ClientView.tsx @@ -6,21 +6,22 @@ import PlayerInfo from './PlayerInfo'; import Footer from '@common/Footer'; import Header from '@common/Header'; import { useClientLive } from '@queries/client/useFetchLive'; +import { getLiveURL } from '@utils/getVideoURL'; const ClientView = () => { const { id: liveId } = useParams(); - const { data: clientLiveData } = useClientLive({ liveId: liveId as string }); + const { data: clientLiveData } = useClientLive({ + liveId: liveId as string + }); - if (!clientLiveData) { - return
๋กœ๋”ฉ ์ค‘...
; - } + const { info } = clientLiveData; return (

ํด๋ผ์ด์–ธํŠธ ํŽ˜์ด์ง€

- - + +