diff --git a/src/assets/icons/ic_arrow_left.svg b/src/assets/icons/ic_arrow_left.svg index 7d7dfc33..0163ed13 100644 --- a/src/assets/icons/ic_arrow_left.svg +++ b/src/assets/icons/ic_arrow_left.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/assets/icons/ic_arrow_right.svg b/src/assets/icons/ic_arrow_right.svg index 2329cd9a..2ba035a9 100644 --- a/src/assets/icons/ic_arrow_right.svg +++ b/src/assets/icons/ic_arrow_right.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/hooks/useDrag.ts b/src/hooks/useDrag.ts new file mode 100644 index 00000000..f0095b43 --- /dev/null +++ b/src/hooks/useDrag.ts @@ -0,0 +1,33 @@ +import { useRef, useState } from 'react'; + +function useDrag() { + const dragRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [clickPoint, setClickPoint] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + const handleMouseDown = (e: React.MouseEvent) => { + setDragging(true); + if (dragRef.current) { + setClickPoint(e.pageX); + setScrollLeft(dragRef.current.scrollLeft); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!dragging) return; + e.preventDefault(); + if (dragRef.current) { + const draggedAmount = e.pageX - clickPoint; + dragRef.current.scrollLeft = scrollLeft - draggedAmount; + } + }; + + const initDragging = () => { + setDragging(false); + }; + + return { dragRef, handleMouseDown, handleMouseMove, initDragging }; +} + +export default useDrag; diff --git a/src/hooks/useInfiniteCarousel.ts b/src/hooks/useInfiniteCarousel.ts index fbff4019..7850de6b 100644 --- a/src/hooks/useInfiniteCarousel.ts +++ b/src/hooks/useInfiniteCarousel.ts @@ -1,10 +1,18 @@ import { useEffect, useRef, useState } from 'react'; +const SWIPE_THRESHOLD = 50; +const NEXT = 1; +const PREVIOUS = -1; + +export type DirectionType = typeof PREVIOUS | typeof NEXT; + const useInfiniteCarousel = (carouselList: Array, x: string) => { const TOTAL_SLIDE = carouselList.length; const [infiniteCarouselList, setInfiniteCarouselList] = useState(carouselList); const [currentIndex, setCurrentIndex] = useState(0); const carouselRef = useRef(null); + const [slideIndex, setSlideIndex] = useState(0); + const [touchStartX, setTouchStartX] = useState(0); useEffect(() => { const firstSide = carouselList[0]; @@ -36,13 +44,52 @@ const useInfiniteCarousel = (carouselList: Array, x: string) => { } }; + const handleSelectSlide = (clickedIndex: number) => { + setSlideIndex(clickedIndex); + handleSwipe(clickedIndex - slideIndex); + }; + + const handleCarouselSwipe = (direction: DirectionType) => { + const totalSlide = carouselList.length; + const newIndex = slideIndex + direction; + if (direction === NEXT) { + setSlideIndex(newIndex === totalSlide ? 0 : newIndex); + } else { + setSlideIndex(newIndex === -1 ? totalSlide - 1 : newIndex); + } + handleSwipe(direction); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + setTouchStartX(e.touches[0].clientX); + }; + + const handleTouchEnd = (e: React.TouchEvent) => { + const endX = e.changedTouches[0].clientX; + const deltaX = touchStartX - endX; + if (deltaX > SWIPE_THRESHOLD) { + handleCarouselSwipe(NEXT); + } else if (deltaX < -SWIPE_THRESHOLD) { + handleCarouselSwipe(PREVIOUS); + } + }; + useEffect(() => { if (carouselRef.current) { carouselRef.current.style.transform = `translateX(calc(${currentIndex} * ${x}))`; } }, [currentIndex, x]); - return { carouselRef, infiniteCarouselList, handleSwipe }; + return { + carouselRef, + infiniteCarouselList, + slideIndex, + handleSelectSlide, + handleCarouselSwipe, + handleSwipe, + handleTouchStart, + handleTouchEnd, + }; }; export default useInfiniteCarousel; diff --git a/src/lib/constants/main.ts b/src/lib/constants/main.ts index 53c16bf1..94b81671 100644 --- a/src/lib/constants/main.ts +++ b/src/lib/constants/main.ts @@ -277,140 +277,160 @@ export const keywordList: KeywordListType = { { content: '린스타트업 기초', ...yellowStyle, - top: '92.97px', - right: '217.71px', + desktop: { top: '92.97px', right: '11.34vw' }, + tablet: { top: '258px', right: '8.59vw' }, + mobile: { top: '148px', right: '10.74vw' }, }, { content: 'IT 프로덕트 기획', ...indigoStyle, - top: '157.13px', - right: '353.2px', + desktop: { top: '157.13px', right: '18.39vw' }, + tablet: { top: '297px', right: '29.03vw' }, + mobile: { top: '177px', right: '27.1vw' }, }, { content: '전반적인 매니징', ...blueStyle, - top: '282.28px', - right: '241.04px', + desktop: { top: '282.28px', right: '12.55vw' }, + tablet: { top: '383px', right: '11.06vw' }, + mobile: { top: '223px', right: '12.86vw' }, }, ], [Part.DESIGN]: [ { content: '피그마와 같은 협업툴', ...skyStyle, - top: '92.97px', - right: '175.71px', + desktop: { top: '92.97px', right: '9.15vw' }, + tablet: { top: '259px', right: '5.72vw' }, + mobile: { top: '149.28px', right: '5.98vw' }, }, { content: 'UX/UI 전반적 과정', ...indigoStyle, - top: '157.13px', - right: '328.2px', + desktop: { top: '157.13px', right: '17.09vw' }, + tablet: { top: '306px', right: '20.18vw' }, + mobile: { top: '176.14px', right: '20.9vw' }, }, { content: '새로운 프로덕트', ...blueStyle, - top: '282.28px', - right: '241.04px', + desktop: { top: '282.28px', right: '12.55vw' }, + tablet: { top: '386px', right: '11.97vw' }, + mobile: { top: '222.02px', right: '12.38vw' }, }, ], [Part.ANDROID]: [ { content: 'UI 구현 기초/심화', ...yellowStyle, - top: '117.6px', - right: '118.42px', + desktop: { top: '117.6px', right: '6.16vw' }, + tablet: { top: '303px', right: '6.38vw' }, + mobile: { top: '175px', right: '6.07vw' }, }, { content: 'Kotlin 언어 활용', ...indigoStyle, - top: '96px', - right: '307.71px', + desktop: { top: '96px', right: 'calc(115px + 10vw)' }, + tablet: { top: '287px', right: 'calc(110px + 10.15vw)' }, + mobile: { top: '163px', right: 'calc(50px + 14.06vw)' }, }, { content: '서버 통신', ...blueStyle, - top: '232px', - right: '141.08px', + desktop: { top: '232px', right: '7.34vw' }, + tablet: { top: '373px', right: '8.85vw' }, + mobile: { top: '218px', right: '10.74vw' }, }, { content: '페어 프로그래밍', - ...greenStyle, - top: '278.38px', - right: '241.04px', + desktop: { top: '278.38px', right: 'calc(50px + 10vw)' }, + tablet: { top: '379px', right: '28.77vw' }, + mobile: { top: '225px', right: '29.43vw' }, }, ], [Part.IOS]: [ { content: 'iOS 앱 서비스', ...indigoStyle, - top: '82.31px', - right: '294.93px', + desktop: { top: '82.31px', right: 'calc(102px + 10vw)' }, + tablet: { top: '305px', right: 'calc(100px + 9.11vw)' }, + mobile: { top: '178px', right: 'calc(50px + 12vw)' }, }, { content: 'Swift와 UI Kit', ...yellowStyle, - top: '117.6px', - right: '129.08px', + desktop: { top: '117.6px', right: '6.72vw' }, + tablet: { top: '324px', right: '6.38vw' }, + mobile: { top: '188px', right: '6.07vw' }, }, { content: '보충 및 심화 세미나', backgroundColor: '#D65438', color: '#fff', - top: '223.74px', - right: '129.08px', + desktop: { top: '223.74px', right: 'calc(33px + 5vw)' }, + tablet: { top: '400px', right: '16.79vw' }, + mobile: { top: '235px', right: '15.42vw' }, }, { content: '왕초보 스터디', ...blueStyle, - top: '275.88px', - right: '365.24px', + desktop: { top: '275.88px', right: '19.02vw' }, + tablet: { top: '360px', right: '39.97vw' }, + mobile: { top: '212px', right: '38.78vw' }, }, ], [Part.WEB]: [ { content: '웹 서비스 개발', ...yellowStyle, - top: '92.97px', - right: '231.71px', + desktop: { top: '92.97px', right: '12.06vw' }, + tablet: { top: '262px', right: '10.93vw' }, + mobile: { top: '158px', right: '13.08vw' }, }, { content: '기초부터 심화까지', ...indigoStyle, - top: '157.13px', - right: '339.2px', + desktop: { top: '157.13px', right: '17.66vw' }, + tablet: { top: '314px', right: '27.47vw' }, + mobile: { top: '188px', right: '29vw' }, }, { content: 'UI구현과 서버 통신', ...skyStyle, - top: '282.28px', - right: '216.04px', + desktop: { top: '282.28px', right: '11.25vw' }, + tablet: { top: '382px', right: '8.33vw' }, + mobile: { top: '228px', right: '10.74vw' }, }, ], [Part.SERVER]: [ { content: '서버 애플리케이션 구축', ...greenStyle, - top: '92.97px', - right: '156.71px', + desktop: { top: '92.97px', right: '8.16vw' }, + tablet: { top: '301px', right: '12.10vw' }, + mobile: { top: '178px', right: '15.42vw' }, }, { content: '관계형 데이터베이스', ...indigoStyle, - top: '157.13px', - right: '101.2px', + desktop: { top: '157.13px', right: 'calc(30px + 3.69vw)' }, + tablet: { top: '346px', right: '5.2vw' }, + mobile: { top: '203px', right: '8.4vw' }, }, { content: 'AWS 기반', ...skyStyle, - top: '192px', - right: '400.2px', + desktop: { top: '192px', right: 'calc(110px + 15.1vw)' }, + tablet: { top: '323px', right: 'calc(110px + 28vw)' }, + mobile: { top: '196px', right: '45.79vw' }, }, { content: 'Spring 프레임 워크', ...blueStyle, - top: '282.28px', - right: '208.04px', + desktop: { top: '282.28px', right: '10.83vw' }, + tablet: { top: '389px', right: '25vw' }, + mobile: { top: '235px', right: '25.7vw' }, }, ], }; diff --git a/src/lib/types/main.ts b/src/lib/types/main.ts index 24946451..6d4f9534 100644 --- a/src/lib/types/main.ts +++ b/src/lib/types/main.ts @@ -23,8 +23,9 @@ type KeywordType = { content: string; backgroundColor: string; color: string; - top: string; - right: string; + desktop: { top: string; right: string }; + tablet: { top: string; right: string }; + mobile: { top: string; right: string }; }; export type KeywordListType = Record; diff --git a/src/views/MainPage/components/PartConfig/PartButton.tsx/style.ts b/src/views/MainPage/components/PartConfig/PartButton.tsx/style.ts index 33626391..24b18edc 100644 --- a/src/views/MainPage/components/PartConfig/PartButton.tsx/style.ts +++ b/src/views/MainPage/components/PartConfig/PartButton.tsx/style.ts @@ -15,4 +15,23 @@ export const PartButton = styled.button<{ isSelected: boolean }>` letter-spacing: -0.84px; cursor: pointer; + + @media (max-width: 768px) { + width: 118.982px; + height: 41.313px; + + font-size: 17.352px; + line-height: 21.552px; /* 124.206% */ + letter-spacing: -0.694px; + } + + @media (max-width: 428px) { + width: 68.38px; + height: 23.743px; + border-radius: 5.698px; + + font-size: 10px; + line-height: 12.386px; /* 123.859% */ + letter-spacing: -0.4px; + } `; diff --git a/src/views/MainPage/components/PartConfig/PartSlide/index.tsx b/src/views/MainPage/components/PartConfig/PartSlide/index.tsx index 4b289ad6..7efeb9ab 100644 --- a/src/views/MainPage/components/PartConfig/PartSlide/index.tsx +++ b/src/views/MainPage/components/PartConfig/PartSlide/index.tsx @@ -1,17 +1,21 @@ -import { ReactComponent as IcArrowLeft } from '@src/assets/icons/ic_arrow_left.svg'; -import { ReactComponent as IcArrowRight } from '@src/assets/icons/ic_arrow_right.svg'; +import Image from 'next/image'; +import IcArrowLeft from '@src/assets/icons/ic_arrow_left.svg'; +import IcArrowRight from '@src/assets/icons/ic_arrow_right.svg'; +import { useIsMobile, useIsTablet } from '@src/hooks/useDevice'; +import { DirectionType } from '@src/hooks/useInfiniteCarousel'; import { keywordList, partList } from '@src/lib/constants/main'; import { Part } from '@src/lib/types/universal'; import * as S from './style'; interface PartSlideProps { part: Part; - handleCarouselSwipe: (direction: number) => void; + handleCarouselSwipe: (direction: DirectionType) => void; } export default function PartSlide({ part, handleCarouselSwipe }: PartSlideProps) { const { value, label, description } = partList[part]; - + const isTablet = useIsTablet('429px', '768px'); + const isMobile = useIsMobile('428px'); const contentDraw = { initial: (custom: number) => ({ opacity: 0, y: 10 * (custom % 2) + 10 }), visible: { @@ -24,7 +28,13 @@ export default function PartSlide({ part, handleCarouselSwipe }: PartSlideProps) return ( - handleCarouselSwipe(-1)} /> + handleCarouselSwipe(-1)} + /> @@ -40,21 +50,30 @@ export default function PartSlide({ part, handleCarouselSwipe }: PartSlideProps) ))} - {keywordList[part].map(({ content, ...style }, index) => ( - - {content} - - ))} + {keywordList[part].map(({ content, desktop, tablet, mobile, ...style }, index) => { + const responsiveStyle = isMobile ? mobile : isTablet ? tablet : desktop; + return ( + + {content} + + ); + })} - handleCarouselSwipe(1)} /> + handleCarouselSwipe(1)} + /> ); diff --git a/src/views/MainPage/components/PartConfig/PartSlide/style.ts b/src/views/MainPage/components/PartConfig/PartSlide/style.ts index 05510642..f1abd0d1 100644 --- a/src/views/MainPage/components/PartConfig/PartSlide/style.ts +++ b/src/views/MainPage/components/PartConfig/PartSlide/style.ts @@ -4,42 +4,92 @@ import { motion } from 'framer-motion'; export const PartSlide = styled.div` display: flex; - justify-content: space-between; flex-shrink: 0; position: relative; width: 100%; height: 428px; - - padding: 77px 0 62px 0; border-radius: 19px; background: linear-gradient(98deg, #1a2035 33.84%, #304f84 96.92%); + + @media (max-width: 768px) { + justify-content: normal; + position: static; + + height: 488.493px; + border-radius: none; + background: none; + } + + @media (max-width: 428px) { + height: 280.74px; + } `; -export const LeftArrow = styled.div` +export const Arrow = styled.div` display: flex; + justify-content: center; align-items: center; - margin-left: 30px; + margin: 0 0 0 1.5625vw; - & > svg:hover { + & > img:hover { cursor: pointer; } + + @media (max-width: 768px) { + width: 26px; + margin: 0 2.73vw 0 0; + } + + @media (max-width: 428px) { + margin: 0 2.72vw 0 0; + width: 15.293px; + & > img { + height: 24.138px; + } + } `; -export const RightArrow = styled.div` - display: flex; - align-items: center; - margin-right: 30px; +export const LeftArrow = styled(Arrow)` + margin: 0 0 0 1.5625vw; - & > svg:hover { - cursor: pointer; + @media (max-width: 768px) { + margin: 0 2.73vw 0 0; + } + + @media (max-width: 428px) { + margin: 0 2.72vw 0 0; + } +`; + +export const RightArrow = styled(Arrow)` + margin: 0 1.5625vw 0 0; + + @media (max-width: 768px) { + margin: 0 0 0 2.73vw; + } + + @media (max-width: 428px) { + margin: 0 0 0 2.72vw; } `; export const Wrapper = styled.div` display: flex; width: 100%; - padding-left: 84px; + padding: 77px 0 62px 4.375vw; + + @media (max-width: 768px) { + position: relative; + padding: 6.57vw 6.5vw 0 6.5vw; + border-radius: 19px; + background: linear-gradient(98deg, #1a2035 33.84%, #304f84 96.92%); + } + + @media (max-width: 428px) { + padding: 6.78vw 6.71vw 0 6.71vw; + border-radius: 10.919px; + } `; export const PartDetail = styled.div` @@ -47,19 +97,33 @@ export const PartDetail = styled.div` flex-direction: column; justify-content: space-between; height: 100%; + width: calc(250px + 11vw); + + @media (max-width: 768px) { + justify-content: normal; + width: 100%; + } `; export const PartTop = styled.div` display: flex; flex-direction: column; gap: 17px; + + @media (max-width: 768px) { + gap: 5px; + } + + @media (max-width: 428px) { + gap: 2.87px; + } `; export const PartBadge = styled.div` display: flex; justify-content: center; align-items: center; - gap: 10px; + /* gap: 10px; */ width: fit-content; padding: 6px 15px; @@ -69,6 +133,26 @@ export const PartBadge = styled.div` background: rgba(255, 255, 255, 0.2); color: ${colors.white}; + font-family: SUIT; + font-size: 19px; + font-style: normal; + font-weight: 400; + line-height: 100%; /* 19px */ + letter-spacing: -0.38px; + + @media (max-width: 768px) { + border-radius: 8.836px; + font-size: 15px; + letter-spacing: -0.3px; + padding: 4.899px 12.247px; + } + + @media (max-width: 428px) { + border-radius: 5.078px; + padding: 2.815px 7.038px; + font-size: 9px; + letter-spacing: -0.18px; + } `; export const PartTitle = styled.div` @@ -78,40 +162,79 @@ export const PartTitle = styled.div` color: #fff; font-family: SUIT; - font-size: 56px; + font-size: calc(48px + 0.41vw); font-style: normal; font-weight: 600; line-height: 100%; /* 56px */ letter-spacing: -1.12px; + + @media (max-width: 768px) { + height: 53.88px; + font-size: max(30px, 4.94vw); + line-height: 96%; /* 36.48px */ + letter-spacing: -0.76px; + + margin-bottom: 1.82vw; + } + + @media (max-width: 428px) { + height: max(28px, 7.21vw); + font-size: max(20px, 5.14vw); + letter-spacing: -0.44px; + margin-bottom: 1.88vw; + } `; export const PartDescription = styled.div` display: inline; - width: 460px; `; export const Content = styled.span<{ weight: 'normal' | 'bold' }>` color: #fff; font-family: SUIT; - font-size: 19px; + font-size: calc(16px + 0.2vw); font-style: normal; font-weight: ${({ weight }) => weight}; line-height: 162%; /* 30.78px */ letter-spacing: -0.38px; + + @media (max-width: 768px) { + font-size: max(14px, 2.21vw); + letter-spacing: -0.34px; + } + + @media (max-width: 428px) { + font-size: max(9px, 2.33vw); + letter-spacing: -0.2px; + } `; export const Keyword = styled(motion.div)` display: flex; justify-content: center; align-items: center; - padding: 18px 25px; + position: absolute; + + padding: calc(13px + 0.26vw) calc(20px + 0.26vw); border-radius: 33px; color: #1b2136; font-family: SUIT; - font-size: 22px; + font-size: calc(16px + 0.3vw); font-style: normal; font-weight: 600; line-height: 162%; /* 35.64px */ letter-spacing: -0.44px; + + @media (max-width: 768px) { + padding: 13.115px 18.215px; + font-size: 16px; + letter-spacing: -0.32px; + } + + @media (max-width: 428px) { + padding: max(6px, 1.76vw) max(8px, 2.44vw); + font-size: 9px; + letter-spacing: -0.18px; + } `; diff --git a/src/views/MainPage/components/PartConfig/index.tsx b/src/views/MainPage/components/PartConfig/index.tsx index 755043ee..bbabed90 100644 --- a/src/views/MainPage/components/PartConfig/index.tsx +++ b/src/views/MainPage/components/PartConfig/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import useDrag from '@src/hooks/useDrag'; import useInfiniteCarousel from '@src/hooks/useInfiniteCarousel'; import { tabs as carouselList } from '@src/lib/constants/tabs'; import { TabType } from '@src/lib/types/universal'; @@ -8,27 +8,16 @@ import Tab from '@src/views/MainPage/components/Tab'; import * as S from './style'; export default function PartConfig() { - const [partIndex, setPartIndex] = useState(0); - const { carouselRef, infiniteCarouselList, handleSwipe } = useInfiniteCarousel( - carouselList, - '(-100% - 20px)', - ); - - const handleSelectPart = (clickedIndex: number) => { - setPartIndex(clickedIndex); - handleSwipe(clickedIndex - partIndex); - }; - - const handleCarouselSwipe = (direction: number) => { - const totalSlide = carouselList.length; - const newIndex = partIndex + direction; - if (direction === 1) { - setPartIndex(newIndex === totalSlide ? 0 : newIndex); - } else { - setPartIndex(newIndex === -1 ? totalSlide - 1 : newIndex); - } - handleSwipe(direction); - }; + const { dragRef, handleMouseDown, handleMouseMove, initDragging } = useDrag(); + const { + carouselRef, + infiniteCarouselList, + slideIndex, + handleSelectSlide, + handleCarouselSwipe, + handleTouchStart, + handleTouchEnd, + } = useInfiniteCarousel(carouselList, '(-100% - 20px)'); return ( @@ -41,19 +30,31 @@ export default function PartConfig() { /> - - {carouselList.map(({ label }, index) => ( - - ))} - + + + {carouselList.map(({ label }, index) => ( + + ))} + + - + {infiniteCarouselList.map(({ value }, index) => ( ))} diff --git a/src/views/MainPage/components/PartConfig/style.ts b/src/views/MainPage/components/PartConfig/style.ts index 9866d656..b45d10a6 100644 --- a/src/views/MainPage/components/PartConfig/style.ts +++ b/src/views/MainPage/components/PartConfig/style.ts @@ -18,9 +18,28 @@ export const PartConfig = styled.section` gap: 23.61px; `; +export const ButtonWrapper = styled.div` + width: 100%; + overflow-x: scroll; + + &:hover { + cursor: grab; + } +`; + export const PartButtonList = styled.div` display: flex; gap: 15px; + + @media (max-width: 768px) { + width: 776px; + gap: 12.39px; + } + + @media (max-width: 428px) { + width: 446px; + gap: 7.12px; + } `; export const CarouselWrapper = styled.div` @@ -37,10 +56,19 @@ export const RequiredAbility = styled(Link)` color: #415678; text-align: right; font-family: SUIT; - font-size: 20px; + font-size: 17px; font-style: normal; font-weight: 400; line-height: 162%; /* 32.4px */ letter-spacing: -0.8px; text-decoration-line: underline; + + @media (max-width: 768px) { + text-align: center; + font-size: 15px; + } + + @media (max-width: 428px) { + font-size: 10px; + } `;