diff --git a/src/apis/message.ts b/src/apis/message.ts index 7298ba0..656f237 100644 --- a/src/apis/message.ts +++ b/src/apis/message.ts @@ -1,39 +1,33 @@ import { client } from './client' export const getMessages = async (page: number, size: number) => { - try { - const { - data: { data }, - } = await client.get('/mainPage/letter', { - params: { - page, - size, - }, - }) - return data - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } + const { + data: { data }, + } = await client.get('/mainPage/letter', { + params: { + page, + size, + }, + }) + if (!data) { + throw new Error('No messages received from server') } + return data } export const getMainData = async () => { - try { - const { - data: { data }, - } = await client.get('/mainPage', { - params: { - page: 0, - size: 1, - }, - }) - return data - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } + const { + data: { data }, + } = await client.get('/mainPage', { + params: { + page: 0, + size: 1, + }, + }) + if (!data) { + throw new Error('No main data received from server') } + return data } export const getSearchResult = async (keyword: string, target: string) => { diff --git a/src/constants/cache.ts b/src/constants/cache.ts new file mode 100644 index 0000000..91f949f --- /dev/null +++ b/src/constants/cache.ts @@ -0,0 +1,2 @@ +export const CACHE_TIME = 1000 * 60 * 60 // 1시간 +export const STALE_TIME = 1000 * 60 * 5 // 5분 diff --git a/src/hooks/useMainData.ts b/src/hooks/useMainData.ts index 496af01..4093f29 100644 --- a/src/hooks/useMainData.ts +++ b/src/hooks/useMainData.ts @@ -1,11 +1,12 @@ import { getMainData } from '@/apis/message' +import { CACHE_TIME, STALE_TIME } from '@/constants/cache' import { useQuery } from '@tanstack/react-query' export const useMainData = () => { return useQuery({ queryKey: ['main-data'], queryFn: () => getMainData(), - staleTime: 1000 * 60 * 5, // 5분 - gcTime: 1000 * 60 * 60, // 1시간 + staleTime: STALE_TIME, + gcTime: CACHE_TIME, }) } diff --git a/src/hooks/useMessage.ts b/src/hooks/useMessage.ts index ea73366..00af8a7 100644 --- a/src/hooks/useMessage.ts +++ b/src/hooks/useMessage.ts @@ -1,4 +1,5 @@ import { getMessages, getMyMessageDetail, getMyMessages } from '@/apis/message' +import { CACHE_TIME, STALE_TIME } from '@/constants/cache' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' const MESSAGE_SIZE = 10 @@ -14,8 +15,8 @@ export const useGetMessages = () => { return allPages.length }, initialPageParam: 0, - staleTime: 1000 * 60 * 5, // 5분 - gcTime: 1000 * 60 * 60, // 1시간 + staleTime: STALE_TIME, + gcTime: CACHE_TIME, }) } @@ -23,8 +24,8 @@ export const useGetMyMessages = () => { return useQuery({ queryKey: ['my-messages'], queryFn: () => getMyMessages(), - staleTime: 1000 * 60 * 5, // 5분 - gcTime: 1000 * 60 * 60, // 1시간 + staleTime: STALE_TIME, + gcTime: CACHE_TIME, }) } @@ -32,8 +33,8 @@ export const useGetMyMessageDetail = (id: string) => { return useQuery({ queryKey: ['my-message-detail', id], queryFn: () => getMyMessageDetail(id), - staleTime: 1000 * 60 * 5, // 5분 - gcTime: 1000 * 60 * 60, // 1시간 + staleTime: STALE_TIME, + gcTime: CACHE_TIME, retry: 0, }) } diff --git a/src/hooks/useNews.tsx b/src/hooks/useNews.tsx index 8b7579a..7ec78d9 100644 --- a/src/hooks/useNews.tsx +++ b/src/hooks/useNews.tsx @@ -1,4 +1,5 @@ import { getNews } from '@/apis/news' +import { CACHE_TIME, STALE_TIME } from '@/constants/cache' import { useInfiniteQuery } from '@tanstack/react-query' const NEWS_SIZE = 10 @@ -14,7 +15,7 @@ export const useGetNews = () => { return allPages.length }, initialPageParam: 0, - staleTime: 1000 * 60 * 5, // 5분 - gcTime: 1000 * 60 * 60, // 1시간 + staleTime: STALE_TIME, + gcTime: CACHE_TIME, }) } diff --git a/src/hooks/useNotices.ts b/src/hooks/useNotices.ts index 7b3f7e1..fa7cf93 100644 --- a/src/hooks/useNotices.ts +++ b/src/hooks/useNotices.ts @@ -1,4 +1,5 @@ import { getNoticeById, getNotices } from '@/apis/notice' +import { CACHE_TIME, STALE_TIME } from '@/constants/cache' import { useInfiniteQuery, useQuery } from '@tanstack/react-query' export const useGetNotices = (size: number) => { @@ -12,8 +13,8 @@ export const useGetNotices = (size: number) => { return allPages.length // 다음 페이지 번호 }, initialPageParam: 0, - staleTime: 1000 * 60 * 5, // 5분 - gcTime: 1000 * 60 * 60, // 1시간 + staleTime: STALE_TIME, + gcTime: CACHE_TIME, }) } @@ -21,7 +22,7 @@ export const useGetNoticeById = (id: string) => { return useQuery({ queryKey: ['notice', id], queryFn: () => getNoticeById(id), - staleTime: 1000 * 60 * 60, // 1시간 - gcTime: 1000 * 60 * 60 * 2, // 2시간 + staleTime: CACHE_TIME, + gcTime: CACHE_TIME * 2, }) } diff --git a/src/hooks/useScrollFade.ts b/src/hooks/useScrollFade.ts new file mode 100644 index 0000000..5e879b7 --- /dev/null +++ b/src/hooks/useScrollFade.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react' + +export const useScrollFade = (targetPosition: number = 430) => { + const [showFade, setShowFade] = useState(false) + + useEffect(() => { + const handleScroll = () => { + const scrollPosition = window.scrollY + setShowFade(scrollPosition > targetPosition) + } + + window.addEventListener('scroll', handleScroll) + return () => window.removeEventListener('scroll', handleScroll) + }, [targetPosition]) + + return showFade +} diff --git a/src/hooks/useShare..ts b/src/hooks/useShare..ts index 26e0079..1f5f964 100644 --- a/src/hooks/useShare..ts +++ b/src/hooks/useShare..ts @@ -1,11 +1,12 @@ import { getSharing } from '@/apis/share' +import { CACHE_TIME, STALE_TIME } from '@/constants/cache' import { useQuery } from '@tanstack/react-query' export const useGetSharing = () => { return useQuery({ queryKey: ['sharing'], queryFn: () => getSharing(), - staleTime: 1000 * 60 * 5, // 5분 - gcTime: 1000 * 60 * 60, // 1시간 + staleTime: STALE_TIME, + gcTime: CACHE_TIME, }) } diff --git a/src/main.tsx b/src/main.tsx index 49374d4..5980a89 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,7 @@ import { createRoot } from 'react-dom/client' import App from '@/App' import '@/styles/index.css' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' import * as Sentry from '@sentry/react' import { browserTracingIntegration, replayIntegration } from '@sentry/react' @@ -16,7 +16,36 @@ Sentry.init({ tracePropagationTargets: ['localhost', import.meta.env.VITE_API_URL], }) -const queryClient = new QueryClient() +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof Error) { + Sentry.captureException(error) + } else { + Sentry.captureMessage('Unknown Query Error', { + extra: { error }, + }) + } + }, + }), + mutationCache: new MutationCache({ + onError: (error) => { + if (error instanceof Error) { + Sentry.captureException(error) + } else { + Sentry.captureMessage('Unknown Mutation Error', { + extra: { error }, + }) + } + }, + }), +}) createRoot(document.getElementById('root')!).render( diff --git a/src/pages/AuthCallback/index.tsx b/src/pages/AuthCallback/index.tsx index 1bbc7e2..6d98cd7 100644 --- a/src/pages/AuthCallback/index.tsx +++ b/src/pages/AuthCallback/index.tsx @@ -17,7 +17,7 @@ export default function AuthCallback() { if (error) { return ( -
+

{error}

diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 3fbea1e..925fe67 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -22,9 +22,9 @@ export default function LoginPage() { return ( <> setIsOpen(false)} /> -
-
-
+
+
+
{ diff --git a/src/pages/Main.tsx b/src/pages/Main.tsx deleted file mode 100644 index 5c5e260..0000000 --- a/src/pages/Main.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import MessageCard from '@/components/MessageCard' -import SolidButton from '@/components/SolidButton' -import TopButton from '@/components/TopButton' -import Header from '@/containers/Main/Header' -import NoticeSection from '@/containers/Main/NoticeSection' -import useBodyBackgroundColor from '@/hooks/useBodyBackgroundColor' -import { useMainData } from '@/hooks/useMainData' -import { useGetMessages } from '@/hooks/useMessage' -import Sidebar from '@/layouts/Sidebar' -import { useEffect, useState } from 'react' -import { useInView } from 'react-intersection-observer' -import { useLocation, useNavigate } from 'react-router-dom' -import { twMerge } from 'tailwind-merge' -import MessageModal from '@/components/MessageModal' -import { extractImgLink } from '@/utils/extractImgLink' -import { PencilIcon } from '@/assets/icons' - -export default function Main() { - const [showFade, setShowFade] = useState(false) - const [showSidebar, setShowSidebar] = useState(false) - const [activeMessage, setActiveMessage] = useState() - const [ref, inView] = useInView() - const [buttonRef, isButtonVisible] = useInView({ - rootMargin: '-40px 0px 100%', - threshold: 0, - }) - const navigate = useNavigate() - const location = useLocation() - useBodyBackgroundColor('#14192F') - - const { data: mainData, refetch: mainDataRefetch } = useMainData() - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - refetch: messagesRefetch, - } = useGetMessages() - - const messages = data?.pages.flatMap((page) => page.openedLetters ?? []) ?? [] - - useEffect(() => { - const handleScroll = () => { - const scrollPosition = window.scrollY - const targetPosition = 430 - setShowFade(scrollPosition > targetPosition) - } - - window.addEventListener('scroll', handleScroll) - return () => window.removeEventListener('scroll', handleScroll) - }, []) - - useEffect(() => { - if (inView && hasNextPage && !isFetchingNextPage) { - fetchNextPage() - } - }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]) - - useEffect(() => { - if (location.state && location.state.from === 'write') { - mainDataRefetch() - messagesRefetch() - } - }, [location]) - - return ( - <> - {activeMessage && ( - setActiveMessage(undefined)} /> - )} - - -
setShowSidebar(true)} /> -
-
- -
-

- 지금까지 {Math.max(mainData?.writtenLetterNumber || 0, messages.length)}개의 -
- 메시지가 모였어요 💌 -

-

- 전체 메시지가 쌓일수록 -
- 도시에 불이 켜져요 -

-
-
-
- 배경 이미지 -
-
-
-
- navigate('/write')} - > - 메시지 작성하기 - -
-
-
- navigate('/write')} - > - 메시지 작성하기 - -
- {messages.map((message, index) => ( - setActiveMessage(message)} - /> - ))} - {messages.length > 0 && hasNextPage &&
} -
-
-
- - ) -} diff --git a/src/pages/Main/components/CityBackground.tsx b/src/pages/Main/components/CityBackground.tsx new file mode 100644 index 0000000..d8acafa --- /dev/null +++ b/src/pages/Main/components/CityBackground.tsx @@ -0,0 +1,22 @@ +import { extractImgLink } from '@/utils/extractImgLink' + +interface CityBackgroundProps { + messageCount: number +} + +const CityBackground = ({ messageCount }: CityBackgroundProps) => { + return ( +
+
+ 배경 이미지 +
+
+
+ ) +} + +export default CityBackground diff --git a/src/containers/Main/Header.tsx b/src/pages/Main/components/Header.tsx similarity index 100% rename from src/containers/Main/Header.tsx rename to src/pages/Main/components/Header.tsx diff --git a/src/pages/Main/components/MessageCounter.tsx b/src/pages/Main/components/MessageCounter.tsx new file mode 100644 index 0000000..f073632 --- /dev/null +++ b/src/pages/Main/components/MessageCounter.tsx @@ -0,0 +1,22 @@ +interface MessageCounterProps { + messageCount: number +} + +const MessageCounter = ({ messageCount }: MessageCounterProps) => { + return ( +
+

+ 지금까지 {messageCount}개의 +
+ 메시지가 모였어요 💌 +

+

+ 전체 메시지가 쌓일수록 +
+ 도시에 불이 켜져요 +

+
+ ) +} + +export default MessageCounter diff --git a/src/pages/Main/components/MessageList.tsx b/src/pages/Main/components/MessageList.tsx new file mode 100644 index 0000000..3c5a3a3 --- /dev/null +++ b/src/pages/Main/components/MessageList.tsx @@ -0,0 +1,29 @@ +import MessageCard from '@/components/MessageCard' + +interface MessageListProps { + messages: MessageType[] + hasNextPage: boolean + observerRef: (node?: Element | null) => void + setActiveMessage: (message: MessageType) => void +} + +export default function MessageList({ + messages, + hasNextPage, + observerRef, + setActiveMessage, +}: MessageListProps) { + return ( +
+ {messages.map((message, index) => ( + setActiveMessage(message)} + /> + ))} + {messages.length > 0 && hasNextPage &&
} +
+ ) +} diff --git a/src/containers/Main/NoticeSection.tsx b/src/pages/Main/components/NoticeSection.tsx similarity index 96% rename from src/containers/Main/NoticeSection.tsx rename to src/pages/Main/components/NoticeSection.tsx index 540e3eb..a9394b4 100644 --- a/src/containers/Main/NoticeSection.tsx +++ b/src/pages/Main/components/NoticeSection.tsx @@ -50,7 +50,7 @@ export default function NoticeSection({ notices }: NoticeSectionProps) { return (
-

공지

+

공지

void +} + +const WriteMessageButton = ({ isVisible, isFixed = false, buttonRef }: WriteMessageButtonProps) => { + const className = isFixed + ? twMerge( + 'fixed bottom-[100px] left-1/2 z-50 -translate-x-1/2 rounded-full py-4 transition-opacity duration-200', + isVisible ? 'pointer-events-none opacity-0' : 'opacity-100' + ) + : 'relative z-10 py-4 mx-auto rounded-full' + + return ( +
+ + + 메시지 작성하기 + + + {!isFixed && ( +
+ )} +
+ ) +} + +export default WriteMessageButton diff --git a/src/pages/Main/index.tsx b/src/pages/Main/index.tsx new file mode 100644 index 0000000..984e360 --- /dev/null +++ b/src/pages/Main/index.tsx @@ -0,0 +1,123 @@ +import TopButton from './components/TopButton' +import Header from './components/Header' +import NoticeSection from './components/NoticeSection' +import useBodyBackgroundColor from '@/hooks/useBodyBackgroundColor' +import { useMainData } from '@/hooks/useMainData' +import { useGetMessages } from '@/hooks/useMessage' +import Sidebar from '@/layouts/Sidebar' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useInView } from 'react-intersection-observer' +import { useLocation } from 'react-router-dom' +import { twMerge } from 'tailwind-merge' +import MessageModal from '@/components/MessageModal' +import MessageList from './components/MessageList' +import WriteMessageButton from './components/WriteMessageButton' +import { useScrollFade } from '@/hooks/useScrollFade' +import CityBackground from './components/CityBackground' +import MessageCounter from './components/MessageCounter' +import SolidButton from '@/components/SolidButton' + +export default function MainPage() { + const [showSidebar, setShowSidebar] = useState(false) + const [activeMessage, setActiveMessage] = useState() + const [ref, inView] = useInView() + const [buttonRef, isButtonVisible] = useInView({ + rootMargin: '-50px 0px 100%', + threshold: 0, + }) + const { state } = useLocation() + const showFade = useScrollFade() + useBodyBackgroundColor('#14192F') + + const { data: mainData, refetch: mainDataRefetch, isError: isMainDataError } = useMainData() + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + refetch: messagesRefetch, + isError: isMessagesError, + } = useGetMessages() + + const messages = useMemo( + () => data?.pages.flatMap((page) => page.openedLetters ?? []) ?? [], + [data?.pages] + ) + + const messageCount = useMemo( + () => Math.max(mainData?.writtenLetterNumber || 0, messages.length), + [mainData?.writtenLetterNumber, messages.length] + ) + + const handleSidebarToggle = useCallback(() => { + setShowSidebar(true) + }, []) + + const handleCloseModal = useCallback(() => { + setActiveMessage(undefined) + }, []) + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]) + + useEffect(() => { + if (state?.from === 'write') { + mainDataRefetch() + messagesRefetch() + } + }, [state?.from]) + + if (isMainDataError || isMessagesError) { + return ( +
+
+

데이터를 불러오는데 실패했습니다.

+ { + mainDataRefetch() + messagesRefetch() + }} + > + 다시 시도하기 + +
+
+ ) + } + + return ( + <> + {activeMessage && } + + +
+
+
+ + + + + +
+ +
+
+ + ) +} diff --git a/src/styles/utilities.css b/src/styles/utilities.css index f977bb9..7cb2b14 100644 --- a/src/styles/utilities.css +++ b/src/styles/utilities.css @@ -15,7 +15,6 @@ display: flex; flex-direction: column; align-items: center; - justify-content: space-between; width: 100%; flex-grow: 1; }