diff --git a/frontend/src/components/common/LazyImage/index.tsx b/frontend/src/components/common/LazyImage/index.tsx index bfc19ad6..a469a675 100644 --- a/frontend/src/components/common/LazyImage/index.tsx +++ b/frontend/src/components/common/LazyImage/index.tsx @@ -7,16 +7,41 @@ interface LazyImageProps extends React.ImgHTMLAttributes { const LazyImage: React.FC = ({ imageUrl, ...props }) => { const lazyImageRef = useRef(null); const observerRef = useRef(); + const timeId = useRef>(); const intersectionCallBack = (entries: IntersectionObserverEntry[], io: IntersectionObserver) => { entries.forEach(entry => { if (!entry.isIntersecting) return; entry.target.setAttribute('src', imageUrl || ''); + io.unobserve(entry.target); }); }; + const onLoad = () => { + if (timeId.current) { + clearTimeout(timeId.current); + } + }; + + const onError = () => { + if (timeId.current) { + clearTimeout(timeId.current); + } + + if (!imageUrl) return; + + const newTimeId = setTimeout(() => { + if (lazyImageRef.current) { + lazyImageRef.current.src = imageUrl; + clearTimeout(timeId.current); + } + }, 3000); + + timeId.current = newTimeId; + }; + useEffect(() => { if (!observerRef.current) { observerRef.current = new IntersectionObserver(intersectionCallBack); @@ -24,10 +49,13 @@ const LazyImage: React.FC = ({ imageUrl, ...props }) => { lazyImageRef.current && observerRef.current.observe(lazyImageRef.current); - return () => observerRef.current && observerRef.current.disconnect(); + return () => { + observerRef.current && observerRef.current.disconnect(); + clearTimeout(timeId.current); + }; }, []); - return ; + return ; }; export default LazyImage; diff --git a/frontend/src/components/user/DetailInfoModal/index.tsx b/frontend/src/components/user/DetailInfoModal/index.tsx index 49577dfc..988625b9 100644 --- a/frontend/src/components/user/DetailInfoModal/index.tsx +++ b/frontend/src/components/user/DetailInfoModal/index.tsx @@ -1,6 +1,6 @@ -import Dimmer from '@/components/common/Dimmer'; +import { useState } from 'react'; -import homeCover_fallback from '@/assets/homeCover-fallback.png'; +import Dimmer from '@/components/common/Dimmer'; import ModalPortal from '@/portals/ModalPortal'; @@ -13,12 +13,21 @@ export interface DetailInfoModalProps { } const DetailInfoModal: React.FC = ({ name, imageUrl, description }) => { + const [isLoadImg, setIsLoadImg] = useState(true); + + const onLoad = () => { + setIsLoadImg(false); + }; + return (

{name}

-
{imageUrl !== '' && }
+
+ {imageUrl !== '' && isLoadImg &&
} + {imageUrl !== '' && } +
{description}
diff --git a/frontend/src/components/user/DetailInfoModal/styles.ts b/frontend/src/components/user/DetailInfoModal/styles.ts index e0269ef8..48274572 100644 --- a/frontend/src/components/user/DetailInfoModal/styles.ts +++ b/frontend/src/components/user/DetailInfoModal/styles.ts @@ -35,10 +35,39 @@ const image = css` object-fit: cover; `; +const skeletonImage = css` + ${image} + + background-color: ${theme.colors.gray200}; + position: relative; + border: none; + overflow: hidden; + + @keyframes loading { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(600px); + } + } + + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 140px; + height: 100%; + background: linear-gradient(to right, ${theme.colors.gray200}, ${theme.colors.gray330}, ${theme.colors.gray200}); + animation: loading 1.4s infinite linear; + } +`; + const description = css` margin: 16px 0; `; -const styles = { container, title, description, imageWrapper, image }; +const styles = { container, title, description, imageWrapper, image, skeletonImage }; export default styles; diff --git a/frontend/src/hooks/useSocket.ts b/frontend/src/hooks/useSocket.ts new file mode 100644 index 00000000..e8e09bad --- /dev/null +++ b/frontend/src/hooks/useSocket.ts @@ -0,0 +1,51 @@ +import { Stomp } from '@stomp/stompjs'; +import { useRef } from 'react'; + +const useSocket = (url: string) => { + const stomp = useRef(null); + const isConnected = useRef(false); + + const checkIsConnected = () => { + return isConnected.current; + }; + + const connectSocket = (callbackConnect: () => void, callbackError: () => void, callbackDisconnect: () => void) => { + stomp.current = Stomp.client(url); + isConnected.current = true; + + stomp.current.reconnect_delay = 1000; + stomp.current.heartbeat.outgoing = 0; + stomp.current.heartbeat.incoming = 0; + + if (process.env.NODE_ENV !== 'development') { + stomp.current.debug = () => {}; + } + + stomp.current.connect({}, callbackConnect, callbackError, callbackDisconnect); + }; + + const disconnectSocket = () => { + if (isConnected.current) { + isConnected.current = false; + stomp.current.disconnect(); + } + }; + + const subscribeTopic = (endPoint: string, callback: (data?: unknown) => void) => { + stomp.current.subscribe(endPoint, callback); + }; + + const sendMessage = (endPoint: string, data: unknown) => { + stomp.current.send(endPoint, {}, JSON.stringify(data)); + }; + + return { + checkIsConnected, + connectSocket, + disconnectSocket, + subscribeTopic, + sendMessage, + }; +}; + +export default useSocket; diff --git a/frontend/src/pages/user/TaskList/index.tsx b/frontend/src/pages/user/TaskList/index.tsx index 794d9f56..0295023f 100644 --- a/frontend/src/pages/user/TaskList/index.tsx +++ b/frontend/src/pages/user/TaskList/index.tsx @@ -1,6 +1,5 @@ import useTaskList from './useTaskList'; import { IoIosArrowBack } from '@react-icons/all-files/io/IoIosArrowBack'; -import React from 'react'; import Button from '@/components/common/Button'; import Sticky from '@/components/common/Sticky'; @@ -14,7 +13,7 @@ const TaskList: React.FC = () => { const { spaceData, onSubmit, - goPreviousPage, + onClickGoPreviousPage, totalCount, checkedCount, percent, @@ -31,7 +30,7 @@ const TaskList: React.FC = () => {
- +
diff --git a/frontend/src/pages/user/TaskList/styles.ts b/frontend/src/pages/user/TaskList/styles.ts index df6acb1f..df881b8d 100644 --- a/frontend/src/pages/user/TaskList/styles.ts +++ b/frontend/src/pages/user/TaskList/styles.ts @@ -8,7 +8,7 @@ const layout = css` width: 100%; align-items: center; font-size: 16px; - padding-bottom: 32px; + padding-bottom: 5em; `; const contents = css` diff --git a/frontend/src/pages/user/TaskList/useTaskList.tsx b/frontend/src/pages/user/TaskList/useTaskList.tsx index 18dec950..4cc553df 100644 --- a/frontend/src/pages/user/TaskList/useTaskList.tsx +++ b/frontend/src/pages/user/TaskList/useTaskList.tsx @@ -1,4 +1,3 @@ -import { Stomp } from '@stomp/stompjs'; import { useEffect, useState } from 'react'; import { useRef } from 'react'; import { useQuery } from 'react-query'; @@ -10,6 +9,7 @@ import NameModal from '@/components/user/NameModal'; import useGoPreviousPage from '@/hooks/useGoPreviousPage'; import useModal from '@/hooks/useModal'; import useSectionCheck from '@/hooks/useSectionCheck'; +import useSocket from '@/hooks/useSocket'; import useToast from '@/hooks/useToast'; import apis from '@/apis'; @@ -18,11 +18,17 @@ import { ID, SectionType } from '@/types'; import { ApiTaskData } from '@/types/apis'; const useTaskList = () => { + const isSubmitted = useRef(false); + const { spaceId, jobId } = useParams() as { spaceId: ID; jobId: ID }; const location = useLocation(); const locationState = location.state as { jobName: string }; + const { checkIsConnected, connectSocket, disconnectSocket, subscribeTopic, sendMessage } = useSocket( + `${process.env.REACT_APP_WS_URL}/ws-connect` + ); + const { openModal, closeModal } = useModal(); const { openToast } = useToast(); @@ -47,9 +53,6 @@ const useTaskList = () => { sectionsData?.sections || [] ); - const stomp = useRef(null); - const isSubmitted = useRef(false); - const onSubmit = (e: React.FormEvent) => { e.preventDefault(); openModal( @@ -67,44 +70,59 @@ const useTaskList = () => { openModal(); }; + const onClickGoPreviousPage = () => { + disconnectSocket(); + goPreviousPage(); + }; + const completeJobs = (author: string) => { - if (!stomp.current) return; isSubmitted.current = true; - stomp.current.send(`/app/jobs/${jobId}/complete`, {}, JSON.stringify({ author })); + sendMessage(`/app/jobs/${jobId}/complete`, { author }); }; const flipTaskCheck = (taskId: ID) => { - if (!stomp.current) return; - stomp.current.send(`/app/jobs/${jobId}/tasks/flip`, {}, JSON.stringify({ taskId })); + sendMessage(`/app/jobs/${jobId}/tasks/flip`, { taskId }); }; const onClickSectionAllCheck = (sectionId: ID) => { - if (!stomp.current) return; - stomp.current.send(`/app/jobs/${jobId}/sections/checkAll`, {}, JSON.stringify({ sectionId })); + sendMessage(`/app/jobs/${jobId}/sections/checkAll`, { sectionId }); }; - useEffect(() => { - stomp.current = Stomp.client(`${process.env.REACT_APP_WS_URL}/ws-connect`); + const onConnectSocket = () => { + subscribeTopic(`/topic/jobs/${jobId}`, (data: any) => { + setSectionsData(JSON.parse(data.body)); + }); - stomp.current.reconnect_delay = 1000; + subscribeTopic(`/topic/jobs/${jobId}/complete`, () => { + closeModal(); + goPreviousPage(); - stomp.current.connect({}, () => { - stomp.current.subscribe(`/topic/jobs/${jobId}`, (data: any) => { - setSectionsData(JSON.parse(data.body)); - }); + isSubmitted.current + ? openToast('SUCCESS', '체크리스트를 제출하였습니다.') + : openToast('ERROR', '해당 체크리스트를 다른 사용자가 제출하였습니다.'); + }); + }; - stomp.current.subscribe(`/topic/jobs/${jobId}/complete`, (data: any) => { - closeModal(); - goPreviousPage(); + const onErrorSocket = () => { + openToast('ERROR', '연결에 문제가 있습니다.'); + disconnectSocket(); + goPreviousPage(); + }; - isSubmitted.current - ? openToast('SUCCESS', '체크리스트를 제출하였습니다.') - : openToast('ERROR', '해당 체크리스트를 다른 사용자가 제출하였습니다.'); - }); - }); + const onDisconnectSocket = () => { + const isConnected = checkIsConnected(); + + if (isConnected) { + openToast('ERROR', '장시간 동작이 없어 이전 페이지로 이동합니다.'); + goPreviousPage(); + } + }; + + useEffect(() => { + connectSocket(onConnectSocket, onErrorSocket, onDisconnectSocket); return () => { - stomp.current.disconnect(); + disconnectSocket(); }; }, []); @@ -115,7 +133,7 @@ const useTaskList = () => { return { spaceData, onSubmit, - goPreviousPage, + onClickGoPreviousPage, totalCount, checkedCount, percent, diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts index 396e981b..d326f28f 100644 --- a/frontend/src/styles/theme.ts +++ b/frontend/src/styles/theme.ts @@ -12,6 +12,7 @@ const theme = Object.freeze({ gray100: '#f9fbfd', gray200: '#f5f5f5', gray300: '#d9d9d9', + gray330: '#EDE4E0', gray350: '#f5f6fa', gray400: '#d1ccc0', gray500: '#84817a',