diff --git a/frontend/src/assets/retry.svg b/frontend/src/assets/retry.svg new file mode 100644 index 000000000..0a268b61f --- /dev/null +++ b/frontend/src/assets/retry.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/PostForm/index.tsx b/frontend/src/components/PostForm/index.tsx index b6f004938..4ebf7002e 100644 --- a/frontend/src/components/PostForm/index.tsx +++ b/frontend/src/components/PostForm/index.tsx @@ -1,7 +1,7 @@ import type { UseMutateFunction } from '@tanstack/react-query'; import React, { HTMLAttributes, useContext, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Navigate, useNavigate } from 'react-router-dom'; import { PostInfo } from '@type/post'; @@ -20,9 +20,11 @@ import SquareButton from '@components/common/SquareButton'; import TimePickerOptionList from '@components/common/TimePickerOptionList'; import WritingVoteOptionList from '@components/optionList/WritingVoteOptionList'; +import { PATH } from '@constants/path'; import { POST_DESCRIPTION_MAX_LENGTH, POST_TITLE_MAX_LENGTH } from '@constants/post'; import { changeCategoryToOption } from '@utils/post/changeCategoryToOption'; +import { checkWriter } from '@utils/post/checkWriter'; import { addTimeToDate, formatTimeWithOption } from '@utils/post/formatTime'; import { getDeadlineTime } from '@utils/post/getDeadlineTime'; @@ -41,6 +43,7 @@ const CATEGORY_COUNT_LIMIT = 3; export default function PostForm({ data, mutate }: PostFormProps) { const { + postId, title, content, category: categoryIds, @@ -48,6 +51,7 @@ export default function PostForm({ data, mutate }: PostFormProps) { deadline, voteInfo, imageUrl, + writer, } = data ?? {}; const navigate = useNavigate(); @@ -140,6 +144,8 @@ export default function PostForm({ data, mutate }: PostFormProps) { } }; + if (postId && writer && !checkWriter(writer.id)) return ; + return ( <> diff --git a/frontend/src/components/common/ErrorMessage/ErrorMessage.stories.tsx b/frontend/src/components/common/ErrorMessage/ErrorMessage.stories.tsx new file mode 100644 index 000000000..3b77283d2 --- /dev/null +++ b/frontend/src/components/common/ErrorMessage/ErrorMessage.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import ErrorMessage from '.'; + +const meta: Meta = { + component: ErrorMessage, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => {}} />, +}; diff --git a/frontend/src/components/common/ErrorMessage/index.tsx b/frontend/src/components/common/ErrorMessage/index.tsx new file mode 100644 index 000000000..824ecc302 --- /dev/null +++ b/frontend/src/components/common/ErrorMessage/index.tsx @@ -0,0 +1,21 @@ +import IconButton from '../IconButton'; +import SquareButton from '../SquareButton'; + +import * as S from './style'; + +export default function ErrorMessage({ errorHandler }: { errorHandler: () => void }) { + return ( + + ⚠ 잠시 후 다시 시도해주세요. + 요청하신 데이터를 불러오는데 실패했습니다. + + + + + 다시 시도 + + + + + ); +} diff --git a/frontend/src/components/common/ErrorMessage/style.ts b/frontend/src/components/common/ErrorMessage/style.ts new file mode 100644 index 000000000..fae76a2b0 --- /dev/null +++ b/frontend/src/components/common/ErrorMessage/style.ts @@ -0,0 +1,67 @@ +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + + position: relative; +`; + +export const HeaderWrapper = styled.div` + width: 100%; + + position: fixed; + + z-index: ${theme.zIndex.header}; + + @media (min-width: ${theme.breakpoint.md}) { + display: none; + } +`; + +export const Title = styled.h1` + width: 90%; + margin-top: 60px; + + font-size: 20px; + font-weight: bold; + + text-align: center; +`; + +export const Description = styled.p` + width: 90%; + margin: 20px 0; + + font: var(--text-body); + text-align: center; +`; + +export const Direction = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; +`; + +export const RetryText = styled.p` + display: flex; + justify-content: space-around; + gap: 10px; + + padding: 12px; + + font: var(--text-body); + font-weight: bold; +`; + +export const ButtonWrapper = styled.div` + width: 120px; + height: 50px; +`; diff --git a/frontend/src/components/common/IconButton/index.tsx b/frontend/src/components/common/IconButton/index.tsx index 4fed26741..6618ad0fd 100644 --- a/frontend/src/components/common/IconButton/index.tsx +++ b/frontend/src/components/common/IconButton/index.tsx @@ -2,11 +2,12 @@ import { ButtonHTMLAttributes } from 'react'; import backIcon from '@assets/back.svg'; import categoryIcon from '@assets/category.svg'; +import retryIcon from '@assets/retry.svg'; import searchIcon from '@assets/search_white.svg'; import * as S from './style'; -type IconCategory = 'category' | 'back' | 'search'; +type IconCategory = 'category' | 'back' | 'search' | 'retry'; const ICON_CATEGORY: Record = { category: { @@ -21,6 +22,10 @@ const ICON_CATEGORY: Record = { name: '검색', url: searchIcon, }, + retry: { + name: '다시시도', + url: retryIcon, + }, }; interface IconButtonProps extends ButtonHTMLAttributes { diff --git a/frontend/src/components/common/SquareButton/index.tsx b/frontend/src/components/common/SquareButton/index.tsx index e3aa07795..40fb9f991 100644 --- a/frontend/src/components/common/SquareButton/index.tsx +++ b/frontend/src/components/common/SquareButton/index.tsx @@ -1,10 +1,10 @@ -import { ButtonHTMLAttributes } from 'react'; +import { ButtonHTMLAttributes, ReactNode } from 'react'; import * as S from './style'; interface SquareButtonProps extends ButtonHTMLAttributes { theme: 'blank' | 'fill' | 'gray'; - children: string; + children: ReactNode; } /* 마감시간, 확인, 취소 등 사용될 버튼 diff --git a/frontend/src/hooks/context/auth.tsx b/frontend/src/hooks/context/auth.tsx index 9f1b3f54b..fa4de4485 100644 --- a/frontend/src/hooks/context/auth.tsx +++ b/frontend/src/hooks/context/auth.tsx @@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (userInfo && loggedInfo.isLoggedIn) { setLoggedInfo(origin => ({ ...origin, userInfo })); } - }, [userInfo]); + }, [loggedInfo.isLoggedIn, userInfo]); useEffect(() => { const accessToken = getCookieToken().accessToken; diff --git a/frontend/src/pages/Error/Error.stories.tsx b/frontend/src/pages/Error/Error.stories.tsx new file mode 100644 index 000000000..a0d197447 --- /dev/null +++ b/frontend/src/pages/Error/Error.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Error from '.'; + +const meta: Meta = { + component: Error, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/frontend/src/pages/Error/index.tsx b/frontend/src/pages/Error/index.tsx new file mode 100644 index 000000000..ef91b0080 --- /dev/null +++ b/frontend/src/pages/Error/index.tsx @@ -0,0 +1,36 @@ +import { useNavigate } from 'react-router-dom'; + +import Layout from '@components/common/Layout'; +import SquareButton from '@components/common/SquareButton'; + +import * as S from './style'; + +export default function Error({ message }: { message?: string }) { + const navigate = useNavigate(); + + return ( + + + {message ? message : '요청 중 오류가 발생했습니다.'} + + { + navigate('/'); + }} + > + 홈으로 가기 + + { + window.location.reload(); + }} + > + 새로 고침 + + + + + ); +} diff --git a/frontend/src/pages/Error/style.ts b/frontend/src/pages/Error/style.ts new file mode 100644 index 000000000..7885fa24c --- /dev/null +++ b/frontend/src/pages/Error/style.ts @@ -0,0 +1,38 @@ +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 40px; + + position: relative; +`; + +export const HeaderWrapper = styled.div` + width: 100%; + + position: fixed; + + z-index: ${theme.zIndex.header}; +`; + +export const Description = styled.p` + width: 90%; + margin-top: 60px; + + font: var(--text-title); + text-align: center; +`; + +export const ButtonWrapper = styled.div` + display: flex; + justify-content: space-between; + gap: 20px; + + width: 280px; + height: 50px; +`; diff --git a/frontend/src/pages/MyInfo/index.tsx b/frontend/src/pages/MyInfo/index.tsx index fa39e32c1..56b3efc6e 100644 --- a/frontend/src/pages/MyInfo/index.tsx +++ b/frontend/src/pages/MyInfo/index.tsx @@ -5,6 +5,7 @@ import { AuthContext } from '@hooks/context/auth'; import { useToggle } from '@hooks/useToggle'; import Accordion from '@components/common/Accordion'; +import GuestProfile from '@components/common/Dashboard/GuestProfile'; import UserProfile from '@components/common/Dashboard/UserProfile'; import IconButton from '@components/common/IconButton'; import Layout from '@components/common/Layout'; @@ -18,12 +19,8 @@ export default function MyInfo() { const navigate = useNavigate(); const { isOpen, openComponent, closeComponent } = useToggle(); - const { userInfo } = useContext(AuthContext).loggedInfo; - - if (!userInfo) { - navigate('/'); - return <>; - } + const { loggedInfo } = useContext(AuthContext); + const { userInfo } = loggedInfo; return ( @@ -39,7 +36,7 @@ export default function MyInfo() { - + {userInfo ? : } diff --git a/frontend/src/pages/NotFound/NotFound.stories.tsx b/frontend/src/pages/NotFound/NotFound.stories.tsx new file mode 100644 index 000000000..e05cb8aef --- /dev/null +++ b/frontend/src/pages/NotFound/NotFound.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import NotFound from '.'; + +const meta: Meta = { + component: NotFound, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => , +}; diff --git a/frontend/src/pages/NotFound/index.tsx b/frontend/src/pages/NotFound/index.tsx new file mode 100644 index 000000000..f577faf73 --- /dev/null +++ b/frontend/src/pages/NotFound/index.tsx @@ -0,0 +1,42 @@ +import { useNavigate } from 'react-router-dom'; + +import IconButton from '@components/common/IconButton'; +import Layout from '@components/common/Layout'; +import LogoButton from '@components/common/LogoButton'; +import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; +import SquareButton from '@components/common/SquareButton'; + +import * as S from './style'; + +export default function NotFound() { + const navigate = useNavigate(); + return ( + + + + + { + navigate(-1); + }} + /> + + + 404 + + 요청하신 페이지를 찾을 수 없어요. + + { + navigate('/'); + }} + > + 홈으로 가기 + + + + + ); +} diff --git a/frontend/src/pages/NotFound/style.ts b/frontend/src/pages/NotFound/style.ts new file mode 100644 index 000000000..1d9447d12 --- /dev/null +++ b/frontend/src/pages/NotFound/style.ts @@ -0,0 +1,46 @@ +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 20px; + + position: relative; +`; + +export const HeaderWrapper = styled.div` + width: 100%; + + position: fixed; + + z-index: ${theme.zIndex.header}; + + @media (min-width: ${theme.breakpoint.md}) { + display: none; + } +`; + +export const Title = styled.h1` + width: 90%; + margin-top: 60px; + + font-size: 80px; + text-align: center; +`; + +export const Description = styled.p` + width: 90%; + margin: 20px 0; + + font: var(--text-title); + text-align: center; +`; + +export const ButtonWrapper = styled.div` + width: 120px; + height: 50px; +`; diff --git a/frontend/src/pages/VoteStatistics/index.tsx b/frontend/src/pages/VoteStatistics/index.tsx index 8f610b4b0..91585e43d 100644 --- a/frontend/src/pages/VoteStatistics/index.tsx +++ b/frontend/src/pages/VoteStatistics/index.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useParams } from 'react-router-dom'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; import { useFetch } from '@hooks/useFetch'; @@ -11,6 +11,10 @@ import LoadingSpinner from '@components/common/LoadingSpinner'; import NarrowTemplateHeader from '@components/common/NarrowTemplateHeader'; import VoteStatistics from '@components/VoteStatistics'; +import { PATH } from '@constants/path'; + +import { checkWriter } from '@utils/post/checkWriter'; + import OptionStatistics from './OptionStatistics'; import * as S from './style'; @@ -25,6 +29,7 @@ export default function VoteStatisticsPage() { errorMessage: postError, isLoading: isPostLoading, } = useFetch(() => getPost(postId)); + const { data: voteResultResponse, errorMessage: voteResultError, @@ -32,9 +37,11 @@ export default function VoteStatisticsPage() { } = useFetch(() => getPostStatistics(postId)); const movePostDetailPage = () => { - navigate(`/posts/${postId}`); + navigate(`${PATH.POST}/${postId}`); }; + if (postDetail && !checkWriter(postDetail.writer.id)) return ; + return ( diff --git a/frontend/src/pages/auth/Redirection.tsx b/frontend/src/pages/auth/Redirection.tsx index 31db378c4..4210131b7 100644 --- a/frontend/src/pages/auth/Redirection.tsx +++ b/frontend/src/pages/auth/Redirection.tsx @@ -5,6 +5,10 @@ import { AuthResponse } from '@type/auth'; import { AuthContext } from '@hooks/context/auth'; +import Error from '@pages/Error'; + +import LoadingSpinner from '@components/common/LoadingSpinner'; + import { getCookieToken, setCookieToken } from '@utils/cookie'; import { getFetch } from '@utils/fetch'; @@ -36,7 +40,9 @@ export default function Redirection() { setErrorMessage(error.message); }) .then(res => { - if (!res) return setErrorMessage('잘못된 형식의 response'); + if (!res) { + return setErrorMessage('로그인 중 오류가 발생했습니다.'); + } const { accessToken } = res; setCookieToken('accessToken', accessToken); @@ -50,12 +56,14 @@ export default function Redirection() { navigate('/'); }); })(); - }, [navigate, loggedInfo, setLoggedInfo]); - - return ( -
- {isLoading && '로그인 중입니다...'} - {errorMessage && errorMessage} -
- ); + }, [navigate, loggedInfo, setLoggedInfo, params]); + + if (isLoading) + return ( +
+ +
+ ); + + if (errorMessage) return ; } diff --git a/frontend/src/routes/PrivateRoute.tsx b/frontend/src/routes/PrivateRoute.tsx new file mode 100644 index 000000000..c439a7506 --- /dev/null +++ b/frontend/src/routes/PrivateRoute.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren } from 'react'; +import { Navigate } from 'react-router-dom'; + +import { PATH } from '@constants/path'; + +import { getCookieToken } from '@utils/cookie'; + +interface Route extends PropsWithChildren { + isAuthenticated?: boolean; + path?: (typeof PATH)[keyof typeof PATH]; +} + +const PrivateRoute = ({ children, isAuthenticated = true, path = PATH.LOGIN }: Route) => { + const isLoggedIn = getCookieToken().accessToken; + + if (!isLoggedIn) { + alert('해당 페이지에 접근하려면 로그인이 필요합니다.'); + + return ; + } + + if (!isAuthenticated) { + alert('해당 페이지에 대한 접근 권한이 없습니다.'); + + return ; + } + + return children; +}; + +export default PrivateRoute; diff --git a/frontend/src/routes/router.tsx b/frontend/src/routes/router.tsx index f35ba5ff6..5cf86b232 100644 --- a/frontend/src/routes/router.tsx +++ b/frontend/src/routes/router.tsx @@ -4,6 +4,7 @@ import Login from '@pages/auth/Login'; import Redirection from '@pages/auth/Redirection'; import Home from '@pages/Home'; import MyInfo from '@pages/MyInfo'; +import NotFound from '@pages/NotFound'; import CreatePost from '@pages/post/CreatePost'; import EditPost from '@pages/post/EditPost'; import PostDetailPage from '@pages/post/PostDetail'; @@ -11,6 +12,8 @@ import VoteStatisticsPage from '@pages/VoteStatistics'; import { PATH } from '@constants/path'; +import PrivateRoute from './PrivateRoute'; + const router = createBrowserRouter([ { path: PATH.HOME, @@ -28,10 +31,21 @@ const router = createBrowserRouter([ { path: PATH.POST, children: [ - { path: 'write', element: }, + { + path: 'write', + element: ( + + + + ), + }, { path: 'write/:postId', - element: , + element: ( + + + + ), }, { path: ':postId', @@ -39,7 +53,11 @@ const router = createBrowserRouter([ }, { path: 'result/:postId', - element: , + element: ( + + + + ), }, { path: 'category/:categoryId', element: }, ], @@ -47,10 +65,21 @@ const router = createBrowserRouter([ { path: PATH.USER, children: [ - { path: 'myPage', element: }, + { + path: 'myPage', + element: ( + + + + ), + }, { path: 'posts', element: }, { path: 'votes', element: }, ], }, + { + path: '*', + element: , + }, ]); export default router; diff --git a/frontend/src/utils/post/checkWriter.ts b/frontend/src/utils/post/checkWriter.ts new file mode 100644 index 000000000..fba8ebb98 --- /dev/null +++ b/frontend/src/utils/post/checkWriter.ts @@ -0,0 +1,8 @@ +import { getCookieToken, getMemberId } from '@utils/cookie'; + +export function checkWriter(writedId: number) { + const accessToken = getCookieToken().accessToken; + const memberId = getMemberId(accessToken).memberId; + + return writedId === memberId; +}