diff --git a/client/src/App.tsx b/client/src/App.tsx index 233c214c6..faca6a168 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,5 @@ -import { lazy, PropsWithChildren, Suspense, useEffect } from 'react'; -import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; import theme from '@styles/theme'; import GlobalStyle from '@styles/GlobalStyle'; import { ThemeProvider } from 'styled-components'; @@ -13,16 +13,14 @@ import RoadmapDetailPage from './pages/roadmapDetailPage/RoadmapDetailPage'; import RoadmapCreatePage from './pages/roadmapCreatePage/RoadmapCreatePage'; import ToastProvider from '@components/_common/toastProvider/ToastProvider'; import MyPage from '@pages/myPage/MyPage'; -import UserInfoProvider, { - useUserInfoContext, -} from './components/_providers/UserInfoProvider'; +import UserInfoProvider from './components/_providers/UserInfoProvider'; import RoadmapSearchResult from './components/roadmapListPage/roadmapSearch/RoadmapSearchResult'; import MainPage from '@pages/mainPage/MainPage'; -import useToast from '@hooks/_common/useToast'; import OAuthRedirect from './components/loginPage/OAuthRedirect'; import AsyncBoundary from './components/_common/errorBoundary/AsyncBoundary'; import SessionHandler from '@components/_common/sessionHandler/SessionHandler'; import RouteChangeTracker from '@components/_common/routeChangeTracker/RouteChangeTracker'; +import PrivateRouter from '@components/_common/privateRouter/PrivateRouter'; const GoalRoomDashboardPage = lazy( () => import('@pages/goalRoomDashboardPage/GoalRoomDashboardPage') @@ -32,22 +30,6 @@ const GoalRoomCreatePage = lazy( () => import('@pages/goalRoomCreatePage/GoalRoomCreatePage') ); -const PrivateRouter = (props: PropsWithChildren) => { - const { children } = props; - const { userInfo } = useUserInfoContext(); - const { triggerToast } = useToast(); - const navigate = useNavigate(); - - useEffect(() => { - if (userInfo.id === null) { - navigate('/login'); - triggerToast({ message: '로그인이 필요한 서비스입니다.' }); - } - }, [userInfo.id, navigate]); - - return <>{children}; -}; - const App = () => { return ( diff --git a/client/src/components/_common/privateRouter/PrivateRouter.tsx b/client/src/components/_common/privateRouter/PrivateRouter.tsx new file mode 100644 index 000000000..c02e0323d --- /dev/null +++ b/client/src/components/_common/privateRouter/PrivateRouter.tsx @@ -0,0 +1,24 @@ +import { PropsWithChildren, useEffect } from 'react'; +import { useUserInfoContext } from '@components/_providers/UserInfoProvider'; +import useToast from '@hooks/_common/useToast'; +import { useNavigate } from 'react-router-dom'; +import { getCookie } from '@utils/_common/cookies'; + +const PrivateRouter = (props: PropsWithChildren) => { + const { children } = props; + const { userInfo } = useUserInfoContext(); + const { triggerToast } = useToast(); + const navigate = useNavigate(); + const accessToken = getCookie('access_token'); + + useEffect(() => { + if (userInfo.id === null && !accessToken) { + navigate('/login'); + triggerToast({ message: '로그인이 필요한 서비스입니다.' }); + } + }, [userInfo.id, navigate]); + + return <>{children}; +}; + +export default PrivateRouter; diff --git a/client/src/components/_common/slider/Slider.styles.ts b/client/src/components/_common/slider/Slider.styles.ts new file mode 100644 index 000000000..25868a8c6 --- /dev/null +++ b/client/src/components/_common/slider/Slider.styles.ts @@ -0,0 +1,58 @@ +import media from '@styles/media'; +import styled, { css } from 'styled-components'; + +export const Slider = styled.div` + position: relative; + + overflow: hidden; + display: flex; + align-items: center; + + width: 100%; +`; + +export const Button = styled.button<{ isHovered: boolean }>` + position: absolute; + + display: flex; + align-items: center; + justify-content: center; + + width: 7rem; + height: 12rem; + + background-color: rgba(1, 1, 1, 0.2); + border-radius: 8px; + box-shadow: ${({ theme }) => theme.shadows.box}; + + ${({ isHovered }) => + media.desktop(css` + opacity: ${isHovered ? 1 : 0}; + transition: opacity 0.2s ease; + `)} +`; + +export const PrevButton = styled(Button)<{ isFirstContentIndex: boolean }>` + left: 2rem; + display: ${({ isFirstContentIndex }) => isFirstContentIndex && 'none'}; +`; + +export const NextButton = styled(Button)<{ isLastContentIndex: boolean }>` + right: 2rem; + display: ${({ isLastContentIndex }) => isLastContentIndex && 'none'}; +`; + +export const Contents = styled.article<{ curIndex: number; length: number }>` + transform: ${({ curIndex }) => `translateX(${-curIndex * 100}%)`}; + display: flex; + width: ${({ length }) => `${length * 100}%`}; + transition: transform 0.3s ease; +`; + +export const Content = styled.div` + flex-shrink: 0; + width: 100%; + & > * { + min-height: 12rem; + } +`; diff --git a/client/src/components/_common/slider/Slider.tsx b/client/src/components/_common/slider/Slider.tsx new file mode 100644 index 000000000..5db87845b --- /dev/null +++ b/client/src/components/_common/slider/Slider.tsx @@ -0,0 +1,43 @@ +import { PropsWithChildren } from 'react'; +import * as S from './Slider.styles'; +import useHover from '@hooks/_common/useHover'; +import SVGIcon from '@components/icons/SVGIcon'; +import useSlider from '@hooks/_common/useSlider'; + +const Slider = ({ children }: PropsWithChildren) => { + const { + curIndex, + slideToPrevContent, + slideToNextContent, + isFirstContentIndex, + isLastContentIndex, + childrenArray, + } = useSlider(children); + const { isHovered, handleMouseEnter, handleMouseLeave } = useHover(); + + return ( + + + {childrenArray.map((child, index) => ( + {child} + ))} + + + + + + + + + ); +}; + +export default Slider; diff --git a/client/src/components/icons/svgIcons.tsx b/client/src/components/icons/svgIcons.tsx index e9dbf5940..1c766aa6a 100644 --- a/client/src/components/icons/svgIcons.tsx +++ b/client/src/components/icons/svgIcons.tsx @@ -2454,3 +2454,52 @@ export const ImageUploadIcon = ({ width, ...props }: SVGProps) => /> ); + +export const LeftIcon = ({ width, ...props }: SVGProps) => ( + + + +); + +export const RightIcon = ({ width, ...props }: SVGProps) => ( + + + +); + +export const NoImageIcon = ({ width, ...props }: SVGProps) => ( + + + +); diff --git a/client/src/components/roadmapCreatePage/difficulty/Difficulty.tsx b/client/src/components/roadmapCreatePage/difficulty/Difficulty.tsx index 8e6dcdb88..c5b07ce0a 100644 --- a/client/src/components/roadmapCreatePage/difficulty/Difficulty.tsx +++ b/client/src/components/roadmapCreatePage/difficulty/Difficulty.tsx @@ -1,31 +1,32 @@ /* eslint-disable react/no-unused-prop-types */ import { useSelect } from '@/hooks/_common/useSelect'; +import { DifficultiesType, DifficultyKeyType } from '@/myTypes/roadmap/internal'; +import { getInvariantObjectKeys, invariantOf } from '@/utils/_common/invariantType'; import { useEffect } from 'react'; import { Select, SelectBox } from '../selector/SelectBox'; import * as S from './difficulty.styles'; -// 임시 더미데이터 -export type DummyDifficultyType = { - [key: number]: string; +const Difficulties: DifficultiesType = { + VERY_EASY: '매우쉬움', + EASY: '쉬움', + NORMAL: '보통', + DIFFICULT: '어려움', + VERY_DIFFICULT: '매우어려움', }; -const DummyDifficulty: DummyDifficultyType = { - 1: '매우쉬움', - 2: '쉬움', - 3: '보통', - 4: '어려움', - 5: '매우어려움', +type DifficultyProps = { + getSelectedDifficulty: (difficulty: DifficultyKeyType | null) => void; }; -type DifficultyType = { - getSelectedDifficulty: (difficulty: keyof DummyDifficultyType | null) => void; -}; - -const Difficulty = ({ getSelectedDifficulty }: DifficultyType) => { +const Difficulty = ({ getSelectedDifficulty }: DifficultyProps) => { const { selectOption, selectedOption } = useSelect(); useEffect(() => { - getSelectedDifficulty(selectedOption); + if (selectedOption === null) return; + + getSelectedDifficulty( + getInvariantObjectKeys(invariantOf(Difficulties))[selectedOption] + ); }, [selectedOption]); return ( @@ -46,7 +47,11 @@ const Difficulty = ({ getSelectedDifficulty }: DifficultyType) => { {({ selectedId }: { selectedId: number | null }) => { return ( - {selectedId === null ? '선택안함' : DummyDifficulty[selectedId]} + {selectedId === null + ? '선택안함' + : Difficulties[ + getInvariantObjectKeys(invariantOf(Difficulties))[selectedId] + ]} ); }} @@ -55,14 +60,14 @@ const Difficulty = ({ getSelectedDifficulty }: DifficultyType) => { - {Object.keys(DummyDifficulty).map((difficultyId) => { + {getInvariantObjectKeys(invariantOf(Difficulties)).map((difficulty, idx) => { return ( - + - + - {DummyDifficulty[Number(difficultyId)]} + {Difficulties[difficulty]} ); diff --git a/client/src/components/roadmapCreatePage/roadmapCreateForm/RoadmapCreateForm.tsx b/client/src/components/roadmapCreatePage/roadmapCreateForm/RoadmapCreateForm.tsx index a84968ee7..2c11169b6 100644 --- a/client/src/components/roadmapCreatePage/roadmapCreateForm/RoadmapCreateForm.tsx +++ b/client/src/components/roadmapCreatePage/roadmapCreateForm/RoadmapCreateForm.tsx @@ -50,6 +50,14 @@ const RoadmapCreateForm = () => { <> + {roadmapValue.roadmapNodes.length === 0 && ( + + )} {roadmapValue.roadmapNodes.map((_, index) => { return ( void; }; const Tag = ({ getTags }: TagProps) => { - const { tags, ref, addTagByButton, addTagByEnter, checkIsTagCountMax, deleteTag } = - useCreateTag(); + const { + tags, + ref, + addTagByButton, + addTagByEnter, + checkIsTagCountMax, + checkIsAddCountMax, + deleteTag, + } = useCreateTag(); useEffect(() => { getTags(tags); @@ -29,8 +36,10 @@ const Tag = ({ getTags }: TagProps) => { ))} - - {checkIsTagCountMax() && +} + {checkIsTagCountMax() && ( + + )} + {checkIsAddCountMax() && +} ); diff --git a/client/src/components/roadmapDetailPage/extraInfo/ExtraInfo.styles.ts b/client/src/components/roadmapDetailPage/extraInfo/ExtraInfo.styles.ts new file mode 100644 index 000000000..98aa0449c --- /dev/null +++ b/client/src/components/roadmapDetailPage/extraInfo/ExtraInfo.styles.ts @@ -0,0 +1,48 @@ +import styled from 'styled-components'; +import media from '@styles/media'; + +export const ExtraInfo = styled.div` + ${({ theme }) => theme.fonts.description5}; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + + width: 30%; + height: 55rem; + + ${media.mobile` + display: none; + `} +`; + +export const RoadmapMetadata = styled.div` + display: flex; + flex-direction: column; + + & > div:not(:last-child) { + margin-bottom: 3rem; + } +`; + +export const Category = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; +`; + +export const Difficulty = styled.div` + text-align: end; +`; + +export const RecommendedRoadmapPeriod = styled.div` + text-align: end; +`; + +export const Tags = styled.div` + color: ${({ theme }) => theme.colors.main_dark}; + + & > div { + text-align: end; + } +`; diff --git a/client/src/components/roadmapDetailPage/extraInfo/ExtraInfo.tsx b/client/src/components/roadmapDetailPage/extraInfo/ExtraInfo.tsx new file mode 100644 index 000000000..4959bca11 --- /dev/null +++ b/client/src/components/roadmapDetailPage/extraInfo/ExtraInfo.tsx @@ -0,0 +1,33 @@ +import { RoadmapDetailType } from '@myTypes/roadmap/internal'; +import * as S from './ExtraInfo.styles'; +import SVGIcon from '@components/icons/SVGIcon'; +import { CategoriesInfo } from '@constants/roadmap/category'; + +type ExtraInfoProps = { + roadmapInfo: RoadmapDetailType; +}; + +const ExtraInfo = ({ roadmapInfo }: ExtraInfoProps) => { + return ( + +
Created by {roadmapInfo.creator.name}
+ + + 카테고리: {roadmapInfo.category.name} + + + 난이도: {roadmapInfo.difficulty} + + 예상 소요시간: {roadmapInfo.recommendedRoadmapPeriod}일 + + + + {roadmapInfo.tags.map((tag) => ( +
#{tag.name}
+ ))} +
+
+ ); +}; + +export default ExtraInfo; diff --git a/client/src/components/roadmapDetailPage/introduction/Introduction.styles.ts b/client/src/components/roadmapDetailPage/introduction/Introduction.styles.ts new file mode 100644 index 000000000..bd5f1e6bf --- /dev/null +++ b/client/src/components/roadmapDetailPage/introduction/Introduction.styles.ts @@ -0,0 +1,47 @@ +import media from '@styles/media'; +import styled from 'styled-components'; + +export const IntroductionWrapper = styled.div` + width: 70%; + + ${media.mobile` + width:100%; + `} +`; + +export const Introduction = styled.div<{ isExpanded: boolean }>` + ${({ theme }) => theme.fonts.description5}; + overflow: hidden; + max-height: ${({ isExpanded }) => (isExpanded ? 'auto' : '55rem')}; + + & > p:not(:last-child) { + margin-bottom: 2rem; + } + + & div { + ${({ theme }) => theme.fonts.h1}; + margin-bottom: 0.5rem; + color: ${({ theme }) => theme.colors.main_dark}; + } +`; + +export const LineShadow = styled.div` + position: relative; + width: 100%; + height: 0.2rem; + box-shadow: 0 -4px 6px rgba(0, 0, 0, 1); +`; + +export const ReadMoreButton = styled.button` + position: relative; + top: calc(-2rem - 4px); + left: 50%; + transform: translateX(-5rem); + + width: 10rem; + height: 4rem; + + background-color: ${({ theme }) => theme.colors.white}; + border-radius: 8px; + box-shadow: ${({ theme }) => theme.shadows.main}; +`; diff --git a/client/src/components/roadmapDetailPage/introduction/Introduction.tsx b/client/src/components/roadmapDetailPage/introduction/Introduction.tsx new file mode 100644 index 000000000..3069efacf --- /dev/null +++ b/client/src/components/roadmapDetailPage/introduction/Introduction.tsx @@ -0,0 +1,51 @@ +import { RoadmapDetailType } from '@myTypes/roadmap/internal'; +import * as S from './Introduction.styles'; +import { useEffect, useRef, useState } from 'react'; + +type IntroductionProps = { + roadmapInfo: RoadmapDetailType; +}; + +const Introduction = ({ roadmapInfo }: IntroductionProps) => { + const [isExpanded, setIsExpanded] = useState(false); + const [showMoreButton, setShowMoreButton] = useState(false); + const introRef = useRef(null); + + useEffect(() => { + if (!introRef.current) return; + + const element = introRef.current; + if (element.scrollHeight > element.clientHeight) { + setShowMoreButton(true); + } + }, []); + + const toggleExpand = () => { + setIsExpanded((prev) => !prev); + }; + + return ( + + +

+

설명
+ {roadmapInfo.introduction} +

+

+

본문
+ {roadmapInfo.content.content === '' + ? '로드맵에 대한 설명이 없어요🥲' + : roadmapInfo.content.content} +

+
+ {showMoreButton && !isExpanded && ( + <> + + 더 보기 + + )} +
+ ); +}; + +export default Introduction; diff --git a/client/src/components/roadmapDetailPage/nodeContent/NodeContent.styles.ts b/client/src/components/roadmapDetailPage/nodeContent/NodeContent.styles.ts new file mode 100644 index 000000000..695e6a26b --- /dev/null +++ b/client/src/components/roadmapDetailPage/nodeContent/NodeContent.styles.ts @@ -0,0 +1,90 @@ +import styled from 'styled-components'; +import media from '@styles/media'; + +export const SliderContent = styled.div` + display: flex; + aspect-ratio: 5 / 3.5; + background-color: ${({ theme }) => theme.colors.gray100}; + border-radius: 8px; + + ${media.mobile` + aspect-ratio: 0; + `} +`; + +export const LeftContent = styled.div` + width: 45%; + + ${media.mobile` + display: none; + `} +`; + +export const NodeImg = styled.img` + width: 100%; + height: 100%; + padding: 1.5rem; + object-fit: cover; +`; + +export const NoImg = styled.div` + ${({ theme }) => theme.fonts.title_large} + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; +`; + +export const Separator = styled.div` + display: flex; + flex-direction: column; + width: 0.2rem; + height: 100%; + + & > div { + height: 50%; + } + + & > div:last-child { + background-color: black; + } +`; + +export const RightContent = styled.div` + ${({ theme }) => theme.fonts.h1} + overflow: scroll; + width: 55%; + padding: 1.5rem; + padding-top: 3rem; + + ${media.mobile` + width: 100%; + height: 60rem; + padding-top: 1.5rem; + `} +`; + +export const ContentTitle = styled.div` + ${({ theme }) => theme.fonts.title_large} + display: flex; + align-items: center; + margin-bottom: 1rem; +`; + +export const Step = styled.div` + ${({ theme }) => theme.fonts.h2} + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 3rem; + height: 3rem; + margin-right: 0.5rem; + + border: 0.3rem solid black; + border-radius: 50%; +`; diff --git a/client/src/components/roadmapDetailPage/nodeContent/NodeContent.tsx b/client/src/components/roadmapDetailPage/nodeContent/NodeContent.tsx new file mode 100644 index 000000000..f2ffb7e60 --- /dev/null +++ b/client/src/components/roadmapDetailPage/nodeContent/NodeContent.tsx @@ -0,0 +1,38 @@ +import type { NodeType } from '@myTypes/roadmap/internal'; +import * as S from './NodeContent.styles'; +import SVGIcon from '@components/icons/SVGIcon'; + +type NodeContentProps = { + node: NodeType; + index: number; +}; + +const NodeContent = ({ node, index }: NodeContentProps) => { + return ( + + + {node.imageUrls[0] ? ( + + ) : ( + + +
No Image
+
+ )} +
+ +
+
+ + + + {index + 1} +

{node.title}

+
+ {node.description} +
+ + ); +}; + +export default NodeContent; diff --git a/client/src/components/roadmapDetailPage/roadmapDetail/RoadmapDetail.styles.ts b/client/src/components/roadmapDetailPage/roadmapDetail/RoadmapDetail.styles.ts index 4cafaf5cf..93c0b17ce 100644 --- a/client/src/components/roadmapDetailPage/roadmapDetail/RoadmapDetail.styles.ts +++ b/client/src/components/roadmapDetailPage/roadmapDetail/RoadmapDetail.styles.ts @@ -1,43 +1,52 @@ -import media from '@styles/media'; import styled from 'styled-components'; export const RoadmapDetail = styled.div` - position: relative; - margin: 4rem 0; - padding: 0 2rem; - white-space: pre-line; - - ${media.mobile` - flex-direction: column; - align-items: center; - `} + padding: 2rem 0 4rem 0; `; -export const RoadmapBody = styled.p` - ${({ theme }) => theme.fonts.button1} - width: 50%; - padding: 4rem 4rem; - height: 35rem; +export const RoadmapInfo = styled.div``; - overflow: scroll; +export const Title = styled.div` + ${({ theme }) => theme.fonts.title_large}; + margin-bottom: 2rem; + color: ${({ theme }) => theme.colors.main_dark}; +`; - border-radius: 18px; - box-shadow: ${({ theme }) => theme.shadows.box}; +export const Description = styled.div` + display: flex; +`; - color: ${({ theme }) => theme.colors.gray300}; +export const ButtonsWrapper = styled.div` + position: relative; + width: 100%; +`; - ${media.mobile` - padding: 4rem 4rem; - `} +export const Buttons = styled.div` + bottom: 3rem; - & > strong { - ${({ theme }) => theme.fonts.h1}; - margin-bottom: 4rem; - color: ${({ theme }) => theme.colors.main_dark}; + display: flex; + align-items: center; + justify-content: space-around; + + margin: 2rem 0; + + background-color: ${({ theme }) => theme.colors.main_dark}; + border-radius: 8px; + + & > div { + width: 0.2rem; + height: 5.5rem; + background-color: ${({ theme }) => theme.colors.white}; } `; -export const PageOnTop = styled.div` - display: flex; - justify-content: space-around; +export const Button = styled.button` + ${({ theme }) => theme.fonts.nav_text} + width: 50%; + height: 5.5rem; + + color: ${({ theme }) => theme.colors.white}; + + background-color: ${({ theme }) => theme.colors.main_dark}; + border-radius: 8px; `; diff --git a/client/src/components/roadmapDetailPage/roadmapDetail/RoadmapDetail.tsx b/client/src/components/roadmapDetailPage/roadmapDetail/RoadmapDetail.tsx index 94da78baa..912425ee5 100644 --- a/client/src/components/roadmapDetailPage/roadmapDetail/RoadmapDetail.tsx +++ b/client/src/components/roadmapDetailPage/roadmapDetail/RoadmapDetail.tsx @@ -1,36 +1,41 @@ -import RoadmapItem from '../../_common/roadmapItem/RoadmapItem'; -import Button from '../../_common/button/Button'; import * as S from './RoadmapDetail.styles'; -import useValidParams from '@/hooks/_common/useValidParams'; +import useValidParams from '@hooks/_common/useValidParams'; import { useNavigate } from 'react-router-dom'; -import { useRoadmapDetail } from '@/hooks/queries/roadmap'; -import RoadmapNodeList from '../roadmapNodeList/RoadmapNodeList'; +import { useRoadmapDetail } from '@hooks/queries/roadmap'; + +import Slider from '@components/_common/slider/Slider'; +import NodeContent from '../nodeContent/NodeContent'; +import ExtraInfo from '../extraInfo/ExtraInfo'; +import Introduction from '../introduction/Introduction'; const RoadmapDetail = () => { const { id: roadmapId } = useValidParams<{ id: string }>(); const navigate = useNavigate(); const { roadmapInfo } = useRoadmapDetail(Number(roadmapId)); - const moveToGoalRoomCreatePage = () => { - navigate(`/roadmap/${roadmapId}/goalroom-create`); - }; - return ( - - - - 로드맵 설명
- {roadmapInfo.content.content === '' - ? '로드맵에 대한 설명이 없어요🥲' - : roadmapInfo.content.content} -
-
- - + + {roadmapInfo.roadmapTitle} + + + + + + + navigate(`/roadmap/${roadmapId}/goalroom-create`)}> + 모임 생성하기 + +
+ navigate(`/roadmap/${roadmapId}/goalroom-list`)}> + 진행중인 모임보기 + + + + {roadmapInfo.content.nodes.map((node, index) => ( + + ))} + ); }; diff --git a/client/src/components/roadmapListPage/roadmapList/RoadmapList.tsx b/client/src/components/roadmapListPage/roadmapList/RoadmapList.tsx index 166299b20..221a65953 100644 --- a/client/src/components/roadmapListPage/roadmapList/RoadmapList.tsx +++ b/client/src/components/roadmapListPage/roadmapList/RoadmapList.tsx @@ -20,7 +20,7 @@ const RoadmapList = ({ selectedCategoryId }: RoadmapListProps) => { }); const loadMoreRef = useInfiniteScroll({ - hasNextPage: roadmapListResponse?.hasNext, + hasNextPage: roadmapListResponse.hasNext, fetchNextPage, }); @@ -35,7 +35,7 @@ const RoadmapList = ({ selectedCategoryId }: RoadmapListProps) => { {roadmapListResponse.responses.map((item) => ( ))} - {roadmapListResponse?.hasNext && } + {roadmapListResponse.hasNext && } + ); diff --git a/client/src/components/roadmapListPage/roadmapSearch/NoResult.tsx b/client/src/components/roadmapListPage/roadmapSearch/NoResult.tsx new file mode 100644 index 000000000..45bcb5ef5 --- /dev/null +++ b/client/src/components/roadmapListPage/roadmapSearch/NoResult.tsx @@ -0,0 +1,7 @@ +import * as S from './roadmapSearch.styles'; + +const NoResult = () => { + return 검색결과가 존재하지 않습니다. 😭; +}; + +export default NoResult; diff --git a/client/src/components/roadmapListPage/roadmapSearch/RoadmapSearch.tsx b/client/src/components/roadmapListPage/roadmapSearch/RoadmapSearch.tsx index 0a97e2943..635c02c44 100644 --- a/client/src/components/roadmapListPage/roadmapSearch/RoadmapSearch.tsx +++ b/client/src/components/roadmapListPage/roadmapSearch/RoadmapSearch.tsx @@ -1,6 +1,6 @@ import { SearchIcon } from '@/components/icons/svgIcons'; import { Select } from '@/components/roadmapCreatePage/selector/SelectBox'; -import { useRef, useState } from 'react'; +import { FormEvent, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import * as S from './roadmapSearch.styles'; @@ -24,11 +24,17 @@ const RoadmapSearch = () => { } }; - const searchRoadmap = () => { + const searchRoadmap = (e: FormEvent) => { + e.preventDefault(); + if (searchWordRef.current?.value === '') return; + navigate(`/roadmap-list/${searchCategory}/${searchWordRef.current?.value}`); }; const resetSearchResult = () => { + if (searchWordRef.current === null) return; + + searchWordRef.current.value = ''; navigate('/roadmap-list'); }; @@ -52,7 +58,7 @@ const RoadmapSearch = () => {

(으)로 검색하기

- + ) => searchRoadmap(e)}> { ref={searchWordRef} /> - + diff --git a/client/src/components/roadmapListPage/roadmapSearch/RoadmapSearchResult.tsx b/client/src/components/roadmapListPage/roadmapSearch/RoadmapSearchResult.tsx index 4382412b1..2f81bdc0a 100644 --- a/client/src/components/roadmapListPage/roadmapSearch/RoadmapSearchResult.tsx +++ b/client/src/components/roadmapListPage/roadmapSearch/RoadmapSearchResult.tsx @@ -4,34 +4,37 @@ import { useSearchRoadmapList } from '@/hooks/queries/roadmap'; import { useInfiniteScroll } from '@/hooks/_common/useInfiniteScroll'; import useValidParams from '@/hooks/_common/useValidParams'; import { useNavigate } from 'react-router-dom'; +import NoResult from './NoResult'; import * as S from './roadmapSearch.styles'; const RoadmapSearchResult = () => { const { category, search } = useValidParams(); - + const navigate = useNavigate(); const { searchRoadmapListResponse, fetchNextPage } = useSearchRoadmapList({ category, search, }); + const { hasNext, responses: roadmapList } = searchRoadmapListResponse; const loadMoreRef = useInfiniteScroll({ - hasNextPage: searchRoadmapListResponse?.hasNext, + hasNextPage: hasNext, fetchNextPage, }); - const navigate = useNavigate(); - const moveRoadmapCreatePage = () => { navigate('/roadmap-create'); }; return ( - - {searchRoadmapListResponse.responses.map((item) => ( - - ))} - {searchRoadmapListResponse?.hasNext && } - + - + <> + {roadmapList.length === 0 && } + + {roadmapList.map((item) => ( + + ))} + {hasNext && } + + + + ); }; diff --git a/client/src/components/roadmapListPage/roadmapSearch/roadmapSearch.styles.ts b/client/src/components/roadmapListPage/roadmapSearch/roadmapSearch.styles.ts index b61f1a4b4..142b511af 100644 --- a/client/src/components/roadmapListPage/roadmapSearch/roadmapSearch.styles.ts +++ b/client/src/components/roadmapListPage/roadmapSearch/roadmapSearch.styles.ts @@ -92,7 +92,7 @@ export const CreateRoadmapButton = styled.button` border-radius: 50%; `; -export const InputFlex = styled.div` +export const InputFlex = styled.form` display: flex; align-items: flex-end; `; @@ -101,3 +101,17 @@ export const ResetSearchButton = styled.button` ${({ theme }) => theme.fonts.description3} color: ${({ theme }) => theme.colors.main_dark}; `; + +export const NoResultWrapper = styled.h1` + ${({ theme }) => theme.fonts.title_large}; + width: 100%; + height: 10rem; + + text-align: center; + + display: flex; + justify-content: center; + align-items: center; + + color: ${({ theme }) => theme.colors.main_dark}; +`; diff --git a/client/src/components/signUpPage/signUpForm/SignUpForm.styles.ts b/client/src/components/signUpPage/signUpForm/SignUpForm.styles.ts index ff02e9d37..9dc8d429d 100644 --- a/client/src/components/signUpPage/signUpForm/SignUpForm.styles.ts +++ b/client/src/components/signUpPage/signUpForm/SignUpForm.styles.ts @@ -39,12 +39,16 @@ export const BoldText = styled.span` `; export const SubmitButton = styled.button` + ${({ theme }) => theme.fonts.button1}; + display: flex; + align-items: center; + justify-content: center; + width: 100%; height: 3rem; margin-top: 1rem; - padding: 1rem; + padding: 2rem; - ${({ theme }) => theme.fonts.button1}; color: ${({ theme }) => theme.colors.white}; background-color: ${({ theme }) => theme.colors.main_dark}; diff --git a/client/src/constants/roadmap/tag.ts b/client/src/constants/roadmap/tag.ts index 8c4b6e9f2..6662ea4e7 100644 --- a/client/src/constants/roadmap/tag.ts +++ b/client/src/constants/roadmap/tag.ts @@ -1,3 +1,3 @@ -export const TAG_LIMIT = 4; +export const TAG_LIMIT = 5; export const TAG_ITEM_MAX_LENGTH = 10; diff --git a/client/src/hooks/_common/useSlider.ts b/client/src/hooks/_common/useSlider.ts new file mode 100644 index 000000000..1204c7552 --- /dev/null +++ b/client/src/hooks/_common/useSlider.ts @@ -0,0 +1,29 @@ +import React, { useState, ReactNode } from 'react'; + +const useSlider = (children: ReactNode) => { + const [curIndex, setCurIndex] = useState(0); + const childrenArray = React.Children.toArray(children) as React.ReactElement[]; + const contentLength = childrenArray.length; + + const isFirstContentIndex = curIndex === 0; + const isLastContentIndex = curIndex === contentLength - 1; + + const slideToPrevContent = () => { + setCurIndex((prev) => (isFirstContentIndex ? prev : prev - 1)); + }; + + const slideToNextContent = () => { + setCurIndex((prev) => (isLastContentIndex ? prev : prev + 1)); + }; + + return { + curIndex, + slideToPrevContent, + slideToNextContent, + isFirstContentIndex, + isLastContentIndex, + childrenArray, + }; +}; + +export default useSlider; diff --git a/client/src/hooks/roadmap/useCollectRoadmapData.ts b/client/src/hooks/roadmap/useCollectRoadmapData.ts index 2bfed9fce..d5b1658f1 100644 --- a/client/src/hooks/roadmap/useCollectRoadmapData.ts +++ b/client/src/hooks/roadmap/useCollectRoadmapData.ts @@ -1,6 +1,9 @@ import { DummyCategoryType } from '@/components/roadmapCreatePage/category/Category'; -import { DummyDifficultyType } from '@/components/roadmapCreatePage/difficulty/Difficulty'; -import { NodeImagesType, RoadmapValueType } from '@/myTypes/roadmap/internal'; +import { + DifficultyKeyType, + NodeImagesType, + RoadmapValueType, +} from '@/myTypes/roadmap/internal'; import { getInvariantObjectKeys, invariantOf } from '@/utils/_common/invariantType'; import { useEffect, useState } from 'react'; import { useCreateRoadmap } from '../queries/roadmap'; @@ -27,7 +30,7 @@ export const useCollectRoadmapData = () => { })); }; - const getSelectedDifficulty = (difficulty: keyof DummyDifficultyType | null) => { + const getSelectedDifficulty = (difficulty: DifficultyKeyType | null) => { setRoadmapValue((prev) => ({ ...prev, difficulty, @@ -106,6 +109,7 @@ export const useCollectRoadmapData = () => { if (isSumbited) { createRoadmap(formData); } + setIsSubmited(false); }, [isSumbited]); return { diff --git a/client/src/hooks/roadmap/useCreateTag.ts b/client/src/hooks/roadmap/useCreateTag.ts index 913ec80c8..28ee13847 100644 --- a/client/src/hooks/roadmap/useCreateTag.ts +++ b/client/src/hooks/roadmap/useCreateTag.ts @@ -6,8 +6,9 @@ export const useCreateTag = () => { const ref = useRef(null); const getAddedTagText = () => { - if (ref.current === null) return; - if (ref.current.value === '') return; + if (!ref.current?.value) return; + + if (tags.includes(ref.current.value)) return; setTags((prev) => { return [...prev, ref.current?.value as string]; @@ -31,6 +32,10 @@ export const useCreateTag = () => { return tags.length < TAG_LIMIT; }; + const checkIsAddCountMax = () => { + return tags.length < TAG_LIMIT - 1; + }; + const deleteTag = (e: React.MouseEvent) => { e.preventDefault(); @@ -40,5 +45,13 @@ export const useCreateTag = () => { return prev.filter((tag) => tag !== target.value); }); }; - return { tags, ref, addTagByButton, addTagByEnter, checkIsTagCountMax, deleteTag }; + return { + tags, + ref, + addTagByButton, + addTagByEnter, + checkIsTagCountMax, + checkIsAddCountMax, + deleteTag, + }; }; diff --git a/client/src/myTypes/roadmap/internal.ts b/client/src/myTypes/roadmap/internal.ts index aeb9a0fbe..76aba525a 100644 --- a/client/src/myTypes/roadmap/internal.ts +++ b/client/src/myTypes/roadmap/internal.ts @@ -1,6 +1,17 @@ import { DIFFICULTY_ICON_NAME } from '@constants/roadmap/difficulty'; import { CategoriesInfo } from '@constants/roadmap/category'; +export type DifficultyKeyType = + | 'VERY_EASY' + | 'EASY' + | 'NORMAL' + | 'DIFFICULT' + | 'VERY_DIFFICULT'; + +export type DifficultyValueType = '매우쉬움' | '쉬움' | '보통' | '어려움' | '매우어려움'; + +export type DifficultiesType = { [key in DifficultyKeyType]: DifficultyValueType }; + export type CategoryType = { id: keyof typeof CategoriesInfo; name: string; @@ -64,7 +75,7 @@ export type RoadmapValueType = { title: null | string; introduction: null | string; content: null | string; - difficulty: null | number; + difficulty: null | DifficultyKeyType; requiredPeriod: null | string; roadmapTags: { name: string }[]; roadmapNodes: RoadmapNodes[]; diff --git a/client/src/styles/GlobalStyle.tsx b/client/src/styles/GlobalStyle.tsx index bc8f7b2d3..90134b3c2 100644 --- a/client/src/styles/GlobalStyle.tsx +++ b/client/src/styles/GlobalStyle.tsx @@ -60,13 +60,14 @@ const GlobalStyle = createGlobalStyle` font-family: 'Noto Sans KR'; font-display: optional; src: url(${require('../assets/fonts/NotoSansKR-Regular.woff')}) format('woff'); + unicode-range: U+0020-007E; } @font-face { font-family: 'Noto Sans'; font-display: optional; src: url(${require('../assets/fonts/NotoSans-Regular.woff')}) format('woff'); - unicode-range: U+0041-005A, U+0061-007A; + unicode-range: U+0020-007E; } :root { diff --git a/client/src/styles/media.ts b/client/src/styles/media.ts index 1efe3875d..6bb9ba128 100644 --- a/client/src/styles/media.ts +++ b/client/src/styles/media.ts @@ -1,4 +1,4 @@ -import { css } from 'styled-components'; +import { RuleSet, css } from 'styled-components'; import BREAK_POINTS from '@constants/_common/breakPoints'; /* @@ -17,20 +17,20 @@ import BREAK_POINTS from '@constants/_common/breakPoints'; */ const media = { - mobile: (styles: TemplateStringsArray) => css` + mobile: (styles: TemplateStringsArray | RuleSet) => css` @media (max-width: ${BREAK_POINTS.MOBILE}px) { ${styles} } `, - tablet: (styles: TemplateStringsArray) => css` + tablet: (styles: TemplateStringsArray | RuleSet) => css` @media (max-width: ${BREAK_POINTS.TABLET}px) { ${styles} } `, - desktop: (styles: TemplateStringsArray) => css` - @media (max-width: ${BREAK_POINTS.DESKTOP}px) { + desktop: (styles: TemplateStringsArray | RuleSet) => css` + @media (min-width: ${BREAK_POINTS.DESKTOP}px) { ${styles} } `,