diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 3b13b0d0..9605c722 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -39,6 +39,7 @@ module.exports = { 'no-unused-expressions': 'off', 'react/jsx-props-no-spreading': 'off', 'react/no-unused-prop-types': 'off', + 'import/no-extraneous-dependencies': 'off', }, settings: { 'import/resolver': { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8931b020..88662ca3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "history": "^5.3.0", + "map-befine-swiper": "^0.8.2", "msw-storybook-addon": "^1.8.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -18306,6 +18307,16 @@ "tmpl": "1.0.5" } }, + "node_modules/map-befine-swiper": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/map-befine-swiper/-/map-befine-swiper-0.8.2.tgz", + "integrity": "sha512-8Adxs0eHS5bqcAgXsX7+bDWALHH7WMx/agxVEUasGh/ABgJbCF1WzNmBQMwkFJ89JoYIn3E5weDA1LAGF832IA==", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "styled-components": "^6.0.8" + } + }, "node_modules/map-obj": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2ab0e1b4..9e661456 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "license": "ISC", "dependencies": { "history": "^5.3.0", + "map-befine-swiper": "^0.8.2", "msw-storybook-addon": "^1.8.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/components/AddSeeTogether/index.tsx b/frontend/src/components/AddSeeTogether/index.tsx index 6d4154eb..9d2604b9 100644 --- a/frontend/src/components/AddSeeTogether/index.tsx +++ b/frontend/src/components/AddSeeTogether/index.tsx @@ -5,10 +5,12 @@ import { deleteApi } from '../../apis/deleteApi'; import { getApi } from '../../apis/getApi'; import { postApi } from '../../apis/postApi'; import { SeeTogetherContext } from '../../context/SeeTogetherContext'; +import useNavigator from '../../hooks/useNavigator'; import useToast from '../../hooks/useToast'; import { TopicCardProps } from '../../types/Topic'; interface AddSeeTogetherProps { + parentType: 'topicCard' | 'topicInfo'; isInAtlas: boolean; id: number; children: React.ReactNode; @@ -17,6 +19,7 @@ interface AddSeeTogetherProps { } function AddSeeTogether({ + parentType, isInAtlas, id, children, @@ -26,6 +29,7 @@ function AddSeeTogether({ const { showToast } = useToast(); const { seeTogetherTopics, setSeeTogetherTopics } = useContext(SeeTogetherContext); + const { routePage } = useNavigator(); const accessToken = localStorage.getItem('userToken'); @@ -34,7 +38,7 @@ function AddSeeTogether({ try { if (seeTogetherTopics && seeTogetherTopics.length === 7) { - showToast('warning', '모아보기는 7개까지만 가능합니다.'); + showToast('error', '모아보기는 7개까지만 가능합니다.'); return; } @@ -69,11 +73,22 @@ function AddSeeTogether({ onClickAtlas(); showToast('info', '해당 지도를 모아보기에서 제외했습니다.'); + + if (parentType === 'topicInfo') routePageAfterSuccessToDelete(topics); } catch { showToast('error', '로그인 후 사용해주세요.'); } }; + const routePageAfterSuccessToDelete = (topics: TopicCardProps[]) => { + if (topics.length === 0) { + routePage(`/`); + return; + } + + routePage(`/see-together/${topics.map((topic) => topic.id).join(',')}`); + }; + const onChangeIsInAtlas = (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/frontend/src/components/Banner/index.tsx b/frontend/src/components/Banner/index.tsx index 83386219..1793b0f0 100644 --- a/frontend/src/components/Banner/index.tsx +++ b/frontend/src/components/Banner/index.tsx @@ -1,11 +1,10 @@ +import { Swiper, Tab } from 'map-befine-swiper'; import styled from 'styled-components'; import BannerItemBoongWEBP from '../../assets/banner_boong.webp'; import BannerItemUsageWEBP from '../../assets/banner_usage.webp'; import useNavigator from '../../hooks/useNavigator'; import Box from '../common/Box'; -import Swiper from '../common/Swiper'; -import Tab from '../common/Swiper/Tab'; const USAGE_URL = 'https://yoondgu.notion.site/3e5b3c98c4814aa1bd5887104fee314e?pvs=4'; @@ -28,6 +27,7 @@ export default function Banner() { height={400} $simpleTab $tabBoxPosition="bottom" + $isNotTabBoxShow swipeable swiper autoplay diff --git a/frontend/src/components/PinImageContainer/index.tsx b/frontend/src/components/PinImageContainer/index.tsx index b663c5ed..e5018a60 100644 --- a/frontend/src/components/PinImageContainer/index.tsx +++ b/frontend/src/components/PinImageContainer/index.tsx @@ -1,16 +1,15 @@ +import { Swiper, Tab } from 'map-befine-swiper'; +import { useState } from 'react'; import styled from 'styled-components'; -import { ImageProps } from '../../types/Pin'; -import Image from '../common/Image'; -import RemoveImageButton from '../../assets/remove_image_icon.svg'; import useDelete from '../../apiHooks/useDelete'; +import RemoveImageButton from '../../assets/remove_image_icon.svg'; +import { ImageModal, useModalContext } from '../../context/ImageModalContext'; import useToast from '../../hooks/useToast'; -import { useModalContext, ImageModal } from '../../context/ImageModalContext'; -import { useState } from 'react'; +import { ImageProps } from '../../types/Pin'; import Button from '../common/Button'; +import Image from '../common/Image'; import Space from '../common/Space'; -import Swiper from '../common/Swiper'; -import Tab from '../common/Swiper/Tab'; interface PinImageContainerProps { images: ImageProps[]; @@ -19,7 +18,7 @@ interface PinImageContainerProps { const NOT_FOUND_IMAGE = 'https://dr702blqc4x5d.cloudfront.net/2023-map-be-fine/icon/notFound_image.svg'; -const PinImageContainer = ({ images, getPinData }: PinImageContainerProps) => { +function PinImageContainer({ images, getPinData }: PinImageContainerProps) { const { fetchDelete } = useDelete(); const { showToast } = useToast(); const { isModalOpen, openModal, closeModal } = useModalContext(); @@ -85,52 +84,50 @@ const PinImageContainer = ({ images, getPinData }: PinImageContainerProps) => { return ( - { - - {images.map((image, index) => ( - - -
onImageOpen(image.imageUrl)}> - -
- onRemovePinImage(image.id)} - > - - -
-
- ))} - {isModalOpen && ( - - - - - - - - )} -
- } + + {images.map((image, index) => ( + + +
onImageOpen(image.imageUrl)}> + +
+ onRemovePinImage(image.id)} + > + + +
+
+ ))} + {isModalOpen && ( + + + + + + + + )} +
); -}; +} const Wrapper = styled.div` width: 330px; diff --git a/frontend/src/components/PinPreview/index.tsx b/frontend/src/components/PinPreview/index.tsx index 8b901686..0ec637f3 100644 --- a/frontend/src/components/PinPreview/index.tsx +++ b/frontend/src/components/PinPreview/index.tsx @@ -1,4 +1,5 @@ import { KeyboardEvent, useContext, useEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { styled } from 'styled-components'; import { TagContext } from '../../context/TagContext'; @@ -11,12 +12,13 @@ import Text from '../common/Text'; export interface PinPreviewProps { idx: number; + pinId: number; + topicId: string; + urlTopicId: string; pinTitle: string; pinLocation: string; pinInformation: string; setSelectedPinId: React.Dispatch>; - pinId: number; - topicId: string; setIsEditPinDetail: React.Dispatch>; } @@ -28,8 +30,10 @@ function PinPreview({ setSelectedPinId, pinId, topicId, + urlTopicId, setIsEditPinDetail, }: PinPreviewProps) { + const { pathname } = useLocation(); const { routePage } = useNavigator(); const { tags, setTags } = useContext(TagContext); const [announceText, setAnnounceText] = useState('토픽 핀 선택'); @@ -63,7 +67,12 @@ function PinPreview({ setSelectedPinId(pinId); setIsEditPinDetail(false); - routePage(`/topics/${topicId}?pinDetail=${pinId}`); + if (pathname.split('/')[1] === 'topics') { + routePage(`/topics/${urlTopicId}?pinDetail=${pinId}`); + return; + } + + routePage(`/see-together/${urlTopicId}?pinDetail=${pinId}`); }; const onInputKeyDown = (e: KeyboardEvent) => { diff --git a/frontend/src/components/PinsOfTopic/index.tsx b/frontend/src/components/PinsOfTopic/index.tsx index 0c3e93a9..0e9622ed 100644 --- a/frontend/src/components/PinsOfTopic/index.tsx +++ b/frontend/src/components/PinsOfTopic/index.tsx @@ -5,6 +5,7 @@ import PinPreview from '../PinPreview'; import TopicInfo from '../TopicInfo'; interface PinsOfTopicProps { + urlTopicId?: string; topicId: string; topicDetail: TopicDetailProps; setSelectedPinId: React.Dispatch>; @@ -13,6 +14,7 @@ interface PinsOfTopicProps { } function PinsOfTopic({ + urlTopicId, topicId, topicDetail, setSelectedPinId, @@ -38,13 +40,14 @@ function PinsOfTopic({ {topicDetail.pins.map((pin, idx) => (
  • diff --git a/frontend/src/components/TopicCard/index.tsx b/frontend/src/components/TopicCard/index.tsx index 03637dbc..3e8f6d3b 100644 --- a/frontend/src/components/TopicCard/index.tsx +++ b/frontend/src/components/TopicCard/index.tsx @@ -154,6 +154,7 @@ function TopicCard({ {cardType === 'default' && getTopicsFromServer && ( { if (currentImage.width < 300) { showToast( - 'warning', + 'error', '이미지의 크기가 너무 작습니다. 다른 이미지를 선택해 주세요.', ); return; } formData.append('image', compressedFile); - await putApi(`/topics/images/${id}`, formData); + try { + await putApi(`/topics/images/${id}`, formData); + } catch { + showToast('error', '사용할 수 없는 이미지입니다.'); + } const updatedImageUrl = URL.createObjectURL(compressedFile); setChangedImages(updatedImageUrl); + showToast('info', '지도 이미지를 수정하였습니다.'); }; }; @@ -177,7 +182,7 @@ function UpdatedTopicInfo({ ) => { diff --git a/frontend/src/components/TopicInfo/index.tsx b/frontend/src/components/TopicInfo/index.tsx index fc251ee9..4635db81 100644 --- a/frontend/src/components/TopicInfo/index.tsx +++ b/frontend/src/components/TopicInfo/index.tsx @@ -67,7 +67,7 @@ function TopicInfo({ }; const onChangeIsInAtlas = () => { - showToast('warning', '비회원은 홈에서만 모아보기에 담을 수 있습니다.'); + showToast('error', '비회원은 홈에서만 모아보기에 담을 수 있습니다.'); return false; }; @@ -155,6 +155,7 @@ function TopicInfo({ {children}; -} - -export default Tab; diff --git a/frontend/src/components/common/Swiper/hooks/useAutoplay.ts b/frontend/src/components/common/Swiper/hooks/useAutoplay.ts deleted file mode 100644 index 97306f7d..00000000 --- a/frontend/src/components/common/Swiper/hooks/useAutoplay.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -interface Props { - autoplay: boolean; - $autoplayTime: number; - childrenListLength: number; - pos: number; - setPos: React.Dispatch>; -} - -const useAutoplay = ({ - autoplay, - $autoplayTime, - childrenListLength, - pos, - setPos, -}: Props) => { - const [isPlaying, setIsPlaying] = useState(autoplay); - const intervalId = useRef(null); - - useEffect(() => { - if (childrenListLength < 2) setIsPlaying(false); - - if (isPlaying) { - intervalId.current = setInterval( - () => { - pos <= childrenListLength - 2 - ? setPos((prev) => prev + 1) - : setPos(0); - }, - $autoplayTime < 1000 ? 1000 : $autoplayTime, - ); - } - - if (!isPlaying && intervalId.current) clearInterval(intervalId.current); - - return () => { - if (intervalId.current) clearInterval(intervalId.current); - }; - }, [childrenListLength, pos, setPos, $autoplayTime, isPlaying]); - - const toggleAutoplay = () => { - setIsPlaying((prev) => !prev); - }; - - return { - isPlaying, - toggleAutoplay, - }; -}; - -export default useAutoplay; diff --git a/frontend/src/components/common/Swiper/hooks/useMatchMedia.ts b/frontend/src/components/common/Swiper/hooks/useMatchMedia.ts deleted file mode 100644 index 72c0c170..00000000 --- a/frontend/src/components/common/Swiper/hooks/useMatchMedia.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -const useMediaQuery = ( - mediaQueries: number[] = [], - defaultValueIgnoreMedia: number = 1, -) => { - const currentPageWidth = window.innerWidth; - - const mediaQueriesIncludeInit = mediaQueries - .concat(currentPageWidth) - .sort((a, b) => b - a); - - const [matches, setMatches] = useState( - mediaQueriesIncludeInit.length > 1 - ? mediaQueriesIncludeInit.length - - mediaQueriesIncludeInit.indexOf(currentPageWidth) - : defaultValueIgnoreMedia, - ); - const mediaQueryListRef = useRef(null); - - useEffect(() => { - mediaQueryListRef.current = mediaQueriesIncludeInit.map( - (mediaQuery) => `(max-width: ${mediaQuery}px)`, - ); - - const handleMediaChange = (elementsCount: number) => { - setMatches(elementsCount); - }; - - mediaQueryListRef.current.forEach((mediaQuery, index) => { - const matchMedia = window.matchMedia(mediaQuery); - - if (mediaQueryListRef.current) { - const mediaQueryConditions = mediaQueryListRef.current; - - matchMedia.addEventListener('change', () => - handleMediaChange(mediaQueryConditions.length - index), - ); - } - }); - - return () => { - if (mediaQueryListRef.current) { - const mediaQueryConditions = mediaQueryListRef.current; - - mediaQueryConditions.forEach((mediaQuery, index) => { - const matchMedia = window.matchMedia(mediaQuery); - - matchMedia.removeEventListener('change', () => - handleMediaChange(mediaQueryConditions.length - index), - ); - }); - } - }; - }, []); - - return { elementsCount: matches }; -}; - -export default useMediaQuery; diff --git a/frontend/src/components/common/Swiper/hooks/useSwipeable.ts b/frontend/src/components/common/Swiper/hooks/useSwipeable.ts deleted file mode 100644 index 4599d239..00000000 --- a/frontend/src/components/common/Swiper/hooks/useSwipeable.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { TouchEventHandler, useRef, useState } from 'react'; - -interface Props { - childrenListLength: number; - pos: number; - setPos: React.Dispatch>; -} - -let isAcceleratingPos = false; -const CAN_SWIPE = 10; -const OPPOSITE_DIRECTION_CAN_SWIPE = 4; - -const useSwipeable = ({ childrenListLength, pos, setPos }: Props) => { - const [prevTouch, setPrevTouch] = useState(null); - const timerId = useRef(null); - - const acceleratePos = (diff: number) => { - if (isAcceleratingPos) return; - - if (pos < childrenListLength - 1 && diff < -CAN_SWIPE) { - isAcceleratingPos = true; - setPos(pos + 1); - } - - if (pos > 0 && diff > CAN_SWIPE) { - isAcceleratingPos = true; - setPos(pos - 1); - } - - if (timerId.current) return; - - timerId.current = setTimeout(() => { - isAcceleratingPos = false; - - if (timerId.current) { - clearTimeout(timerId.current); - timerId.current = null; - } - }, 150); - }; - - const increasePos = () => { - if (pos < childrenListLength - 1) setPos(pos + 1); - }; - - const decreasePos = () => { - if (pos > 0) setPos(pos - 1); - }; - - const moveToSettedPos = (settedPos: number) => { - setPos(settedPos); - }; - - const handleTouchMove: TouchEventHandler = (event) => { - const touch = event.touches[0]!; - - setPrevTouch(touch); - if (!prevTouch) return; - - const diff = touch.pageX - prevTouch.pageX; - const otherPos = touch.pageY - prevTouch.pageY; - - if (Math.abs(otherPos) > OPPOSITE_DIRECTION_CAN_SWIPE) return; - - acceleratePos(diff); - }; - - const handleTouchEnd: TouchEventHandler = () => { - setPrevTouch(null); - }; - - return { - pos, - increasePos, - decreasePos, - moveToSettedPos, - handleTouchMove, - handleTouchEnd, - }; -}; - -export default useSwipeable; diff --git a/frontend/src/components/common/Swiper/index.tsx b/frontend/src/components/common/Swiper/index.tsx deleted file mode 100644 index afa8a005..00000000 --- a/frontend/src/components/common/Swiper/index.tsx +++ /dev/null @@ -1,379 +0,0 @@ -import React, { useState } from 'react'; -import styled, { css } from 'styled-components'; - -import SwiperLeftBtnSVG from '../../../assets/swiper_left_button.svg'; -import SwiperRightBtnSVG from '../../../assets/swiper_right_button.svg'; -import useAutoplay from './hooks/useAutoplay'; -import useMediaQuery from './hooks/useMatchMedia'; -import useSwipeable from './hooks/useSwipeable'; - -type TabBoxPositionType = 'top' | 'bottom'; - -interface TabProps { - label: string; -} - -interface Props { - children: React.ReactNode; - width?: number; - height?: number; - $tabBoxHeight?: number; - $tabColor?: string | string[]; - $tabBoxPosition?: TabBoxPositionType; - $elementsOneTab?: number; - $focusColor?: string; - $simpleTab?: boolean; - $isNotTabBoxShow?: boolean; - responsive?: boolean; - swiper?: boolean; - swipeable?: boolean; - autoplay?: boolean; - $autoplayTime?: number; - $autoplayButton?: boolean; - $elementsMediaQueries?: number[]; - as?: string; -} - -const getTabsColor = (indexOfTab: number, $tabColor: string | string[]) => { - if (typeof $tabColor === 'string') return $tabColor; - - return $tabColor[indexOfTab] || '#e4e4e4'; -}; - -const calculateTabCountUsingElements = ( - childrenList: React.ReactElement[], - $elementsOneTab: number, -) => { - if ($elementsOneTab > 1) { - const tabBoxesCount = Math.ceil(childrenList.length / $elementsOneTab); - return childrenList.filter((_, idx) => idx < tabBoxesCount); - } - - return childrenList; -}; - -function Swiper({ - width = 400, - height = 400, - $simpleTab = false, - $isNotTabBoxShow = false, - $tabBoxHeight = height / 10, - $tabBoxPosition = 'top', - $elementsOneTab = 1, - $tabColor = '#e4e4e4', - $focusColor = '#316fc4', - responsive = true, - swiper = false, - swipeable = false, - autoplay = false, - $autoplayTime = 5000, - $autoplayButton = false, - $elementsMediaQueries = [], - as = 'div', - children, -}: Props) { - const childrenList = React.Children.toArray( - children, - ) as React.ReactElement[]; - const isShowTabBox = - !$isNotTabBoxShow && childrenList.length > $elementsOneTab; - - const [pos, setPos] = useState(0); - const { elementsCount } = useMediaQuery( - $elementsMediaQueries, - $elementsOneTab, - ); - const { - increasePos, - decreasePos, - moveToSettedPos, - handleTouchMove, - handleTouchEnd, - } = useSwipeable({ - childrenListLength: calculateTabCountUsingElements( - childrenList, - elementsCount, - ).length, - pos, - setPos, - }); - const { isPlaying, toggleAutoplay } = useAutoplay({ - autoplay, - $autoplayTime, - childrenListLength: calculateTabCountUsingElements( - childrenList, - elementsCount, - ).length, - pos, - setPos, - }); - - return ( - - {isShowTabBox && ( - - {calculateTabCountUsingElements(childrenList, elementsCount).map( - (children, idx) => - children && ( - moveToSettedPos(idx)} - > - {!$simpleTab && (children.props.label || idx + 1)} - - ), - )} - - )} - - - {children} - - - {swiper && ( - <> - - - - - - - - )} - - {$autoplayButton && childrenList.length > 1 && ( - - {isPlaying ? ( - || - ) : ( - ▶️ - )} - - )} - - ); -} - -const Wrapper = styled.div<{ - width: number; - responsive: boolean; - $tabBoxPosition: TabBoxPositionType; -}>` - width: ${({ width }) => `${width}px`}; - overflow: hidden; - margin: 0 auto; - position: relative; - display: flex; - flex-direction: ${({ $tabBoxPosition }) => - $tabBoxPosition === 'top' ? 'column' : 'column-reverse'}; - - // responsive가 true 이면 입력 받은 width 값 보다 뷰포트가 작아질 때 뷰포트에 맞게 width를 설정합니다. - ${({ responsive, width }) => - responsive && - css` - @media (max-width: ${width}px) { - width: 100%; - } - `} -`; - -const calculateWidthUsingElementsCount = ( - width: number | '100vw', - elementCount: number, -) => { - if (typeof width === 'number' && elementCount > 1) - return width / elementCount; - if (typeof width === 'string' && elementCount > 1) - return `calc(${width} / ${elementCount})`; - - return width; -}; - -const TabSectionWrapper = styled.div<{ - width: number; - height: number; - $childrenLength: number; - pos: number; - responsive: boolean; - $elementsOneTab: number; -}>` - display: flex; - - // 사용자가 입력한 의 width 값을 컴포넌트 개수만큼 곱하여 Tab Section의 width를 설정합니다. 따라서 overflow 됩니다. - width: ${({ width, $childrenLength }) => `${width * $childrenLength}px`}; - height: ${({ height }) => `${height}px`}; - - // 사용자가 입력한 width 값 보다 컴포넌트 개수만큼 overflow 되었으므로 width * index 만큼 transform 하여 요소의 위치로 이동합니다. (전환 효과) - transform: ${({ width, pos }) => `translateX(${-width * pos}px)`}; - transition: 0.3s ease transform; - - ${({ responsive, width, $childrenLength, pos }) => - responsive && - css` - @media (max-width: ${width}px) { - width: calc(100vw * ${$childrenLength}); - height: auto; - transform: translateX(calc(-100vw * ${pos})); - } - `} - - // 컴포넌트에 입력한 width와 컴포넌트 width를 동일하게 합니다. - & > * { - width: ${({ width, $elementsOneTab }) => - calculateWidthUsingElementsCount(width, $elementsOneTab)}px; - - ${({ responsive, width, $elementsOneTab }) => - responsive && - css` - @media (max-width: ${width}px) { - width: ${calculateWidthUsingElementsCount('100vw', $elementsOneTab)}; - } - `} - } -`; - -const TabBoxWrapper = styled.div<{ - $simpleTab: boolean; - $tabBoxHeight: number; -}>` - display: flex; - align-items: center; - - // 기본값은 height / 10 입니다. 하지만 사용자가 직접 지정할 수도 있게 해두었습니다. - height: ${({ $tabBoxHeight }) => `${$tabBoxHeight}px`}; - overflow: auto; - - // simpleTab 모드에서는 TabButton이 작아지므로 가운데 정렬하도록 합니다. - ${({ $simpleTab }) => - $simpleTab && - css` - justify-content: center; - `} -`; - -const TabBox = styled.button<{ - width: number; - idx: number; - pos: number; - $tabBoxHeight: number; - $childrenLength: number; - $simpleTab: boolean; - $tabColor: string; - $focusColor: string; -}>` - width: ${({ width, $childrenLength }) => `${width / $childrenLength}px`}; - height: inherit; - padding: 0.2rem 1rem; - border: 0; - cursor: pointer; - background-color: ${({ $tabColor }) => $tabColor}; - - // simpleTab 모드일 때 탭 박스 대신 원이 생성됩니다. 해당 원의 크기를 지정합니다. - ${({ $simpleTab, $tabBoxHeight }) => - $simpleTab && - css` - padding: 0; - width: ${$tabBoxHeight / 2}px; - height: ${$tabBoxHeight / 2}px; - border-radius: 50%; - `} - - // simpleTab 모드에서 원들이 가운데 정렬되므로 각 요소의 margin을 설정합니다. - ${({ $simpleTab }) => - $simpleTab && - css` - margin-right: 12px; - - &:last-of-type { - margin-right: 0; - } - `} - - // simpleTab 모드가 아닐때, 포커스 된 탭 박스 하단 밑줄 구현부입니다. - ${({ idx, pos, $focusColor }) => - idx === pos && - css` - border-bottom: 2px solid ${$focusColor}; - `} - - // simpleTab 모드 일 때, 포커스 된 원 배경색 구현부입니다. - ${({ $simpleTab, idx, pos, $focusColor }) => - idx === pos && - $simpleTab && - css` - border-bottom: 0; - border: 2px solid ${$focusColor}; - `} -`; - -const SwiperButtonLeftWrapper = styled.div<{ $tabBoxHeight: number }>` - position: absolute; - top: ${({ $tabBoxHeight }) => `calc(50% + ${$tabBoxHeight}px / 2)`}; - left: 1%; - transform: ${({ $tabBoxHeight }) => - `translateY(calc(-50% - ${$tabBoxHeight}px))`}; - - @media (max-width: 744px) { - display: none; - } -`; - -const SwiperButtonRightWrapper = styled.div<{ $tabBoxHeight: number }>` - position: absolute; - top: ${({ $tabBoxHeight }) => `calc(50% + ${$tabBoxHeight}px / 2)`}; - right: 1%; - transform: ${({ $tabBoxHeight }) => - `translateY(calc(-50% - ${$tabBoxHeight}px))`}; - - @media (max-width: 744px) { - display: none; - } -`; - -const AutoplayButtonWrapper = styled.div` - width: 100%; - display: flex; - justify-content: center; - position: absolute; - bottom: 12%; -`; - -const AutoplayButton = styled.div` - display: flex; - justify-content: center; - align-items: center; - background-color: transparent; - color: rgba(149, 149, 149, 0.8); - border: 1px solid rgba(149, 149, 149, 0.8); - width: 64px; - height: 32px; - cursor: pointer; -`; - -export default Swiper; diff --git a/frontend/src/context/MarkerContext.tsx b/frontend/src/context/MarkerContext.tsx index 500f0f57..2410c0e3 100644 --- a/frontend/src/context/MarkerContext.tsx +++ b/frontend/src/context/MarkerContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import { pinColors, pinImageMap } from '../constants/pinImage'; import useNavigator from '../hooks/useNavigator'; @@ -41,6 +41,7 @@ function MarkerProvider({ children }: Props): JSX.Element { const { coordinates, clickedCoordinate } = useContext(CoordinatesContext); const { topicId } = useParams<{ topicId: string }>(); const { routePage } = useNavigator(); + const { pathname } = useLocation(); const createMarker = ( coordinate: Coordinate, @@ -87,7 +88,12 @@ function MarkerProvider({ children }: Props): JSX.Element { newMarkers.forEach((marker: Marker) => { marker.on('click', () => { - routePage(`/topics/${topicId}?pinDetail=${marker.id}`); + if (pathname.split('/')[1] === 'topics') { + routePage(`/topics/${topicId}?pinDetail=${marker.id}`); + return; + } + + routePage(`/see-together/${topicId}?pinDetail=${marker.id}`); }); }); setMarkers(newMarkers); diff --git a/frontend/src/context/SeeTogetherContext.tsx b/frontend/src/context/SeeTogetherContext.tsx index 292551ea..67a5d917 100644 --- a/frontend/src/context/SeeTogetherContext.tsx +++ b/frontend/src/context/SeeTogetherContext.tsx @@ -6,8 +6,6 @@ import { useState, } from 'react'; -import { TopicCardProps } from '../types/Topic'; - interface SeeTogetherContextProps { seeTogetherTopics: number[] | null; setSeeTogetherTopics: Dispatch>; diff --git a/frontend/src/hooks/useNavigator.ts b/frontend/src/hooks/useNavigator.ts index b6d0adbc..52a54151 100644 --- a/frontend/src/hooks/useNavigator.ts +++ b/frontend/src/hooks/useNavigator.ts @@ -21,7 +21,7 @@ const useNavigator = () => { home: () => routePage('/'), seeTogether: () => routePage( - `/topics/${ + `/see-together/${ seeTogetherTopics?.length === 0 ? -1 : seeTogetherTopics?.join(',') }`, ), diff --git a/frontend/src/hooks/useResizeMap.ts b/frontend/src/hooks/useResizeMap.ts new file mode 100644 index 00000000..57e074df --- /dev/null +++ b/frontend/src/hooks/useResizeMap.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +import useMapStore from '../store/mapInstance'; + +const getAvailableWidth = (sidebarWidth: number = 372) => + window.innerWidth - sidebarWidth; + +const getAvailableHeight = () => window.innerHeight; + +const useResizeMap = () => { + const { mapInstance } = useMapStore((state) => state); + + const resizeMap = () => { + if (!mapInstance) return; + + mapInstance.resize(getAvailableWidth(372), getAvailableHeight()); + }; + + useEffect(() => { + if (window.innerWidth > 1180) resizeMap(); + }, [getAvailableWidth(372)]); + + return { resizeMap }; +}; + +export default useResizeMap; diff --git a/frontend/src/hooks/useSetNavbarHighlight.ts b/frontend/src/hooks/useSetNavbarHighlight.ts index 54e198c9..7b95567f 100644 --- a/frontend/src/hooks/useSetNavbarHighlight.ts +++ b/frontend/src/hooks/useSetNavbarHighlight.ts @@ -6,22 +6,38 @@ import { NavbarHighlightsContext, } from '../context/NavbarHighlightsContext'; -const navbarPageNames: NavbarHighlightKeys[] = [ +type NavbarPageNamesType = 'none' | NavbarHighlightKeys; + +const navbarPageNames: NavbarPageNamesType[] = [ 'home', 'seeTogether', 'addMapOrPin', 'favorite', 'profile', + 'none', ]; -const useSetNavbarHighlight = (pageName: NavbarHighlightKeys) => { +const deleteNavbarHighlights = { + home: false, + seeTogether: false, + addMapOrPin: false, + favorite: false, + profile: false, +}; + +const useSetNavbarHighlight = (pageName: NavbarPageNamesType) => { const { navbarHighlights, setNavbarHighlights } = useContext( NavbarHighlightsContext, ); useEffect(() => { + if (pageName === 'none') { + setNavbarHighlights(deleteNavbarHighlights); + return; + } + const newNavbarHighlights: NavbarHighlights = navbarPageNames.reduce( - (acc, curr) => ({ ...acc, [curr]: curr === pageName }), + (acc, cur) => ({ ...acc, [cur]: cur === pageName }), {} as NavbarHighlights, ); diff --git a/frontend/src/hooks/useTags.ts b/frontend/src/hooks/useTags.ts new file mode 100644 index 00000000..fa3e2ad6 --- /dev/null +++ b/frontend/src/hooks/useTags.ts @@ -0,0 +1,21 @@ +import { useContext } from 'react'; + +import { TagContext } from '../context/TagContext'; +import useNavigator from './useNavigator'; + +const useTags = () => { + const { tags, setTags } = useContext(TagContext); + const { routePage } = useNavigator(); + + const onClickCreateTopicWithTags = () => { + routePage('/new-topic', tags.map((tag) => tag.id).join(',')); + }; + + const onClickInitTags = () => { + setTags([]); + }; + + return { tags, setTags, onClickInitTags, onClickCreateTopicWithTags }; +}; + +export default useTags; diff --git a/frontend/src/pages/AskLogin.tsx b/frontend/src/pages/AskLogin.tsx index 69a2101c..559a18b5 100644 --- a/frontend/src/pages/AskLogin.tsx +++ b/frontend/src/pages/AskLogin.tsx @@ -7,9 +7,11 @@ import Space from '../components/common/Space'; import Text from '../components/common/Text'; import { DEFAULT_PROD_URL, FULLSCREEN } from '../constants'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; function AskLogin() { const { width } = useSetLayoutWidth(FULLSCREEN); + useSetNavbarHighlight('none'); const loginButtonClick = async () => { window.location.href = `${DEFAULT_PROD_URL}/oauth/kakao`; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 5350ca3e..4aa859ea 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -12,12 +12,14 @@ import { SeeTogetherContext } from '../context/SeeTogetherContext'; import useNavigator from '../hooks/useNavigator'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; +import useToast from '../hooks/useToast'; const TopicListContainer = lazy( () => import('../components/TopicCardContainer'), ); function Home() { + const accessToken = localStorage.getItem('userToken'); const { routingHandlers } = useNavigator(); const { goToPopularTopics, goToLatestTopics, goToNearByMeTopics } = routingHandlers; @@ -25,7 +27,7 @@ function Home() { useContext(SeeTogetherContext); const { markers, removeMarkers, removeInfowindows } = useContext(MarkerContext); - const accessToken = localStorage.getItem('userToken'); + const { showToast } = useToast(); useSetLayoutWidth(FULLSCREEN); useSetNavbarHighlight('home'); @@ -33,6 +35,7 @@ function Home() { useEffect(() => { if (accessToken === null && seeTogetherTopics?.length !== 0) { setSeeTogetherTopics([]); + showToast('info', '로그인을 하면 모아보기가 유지돼요.'); } }, []); diff --git a/frontend/src/pages/NewTopic.tsx b/frontend/src/pages/NewTopic.tsx index 80986cdf..7d1a75b2 100644 --- a/frontend/src/pages/NewTopic.tsx +++ b/frontend/src/pages/NewTopic.tsx @@ -142,7 +142,7 @@ function NewTopic() { currentImage.onload = () => { if (currentImage.width < 300) { showToast( - 'warning', + 'error', '이미지의 크기가 너무 작습니다. 다른 이미지를 선택해 주세요.', ); return; diff --git a/frontend/src/pages/SeeTogether.tsx b/frontend/src/pages/SeeTogether.tsx new file mode 100644 index 00000000..c13cef55 --- /dev/null +++ b/frontend/src/pages/SeeTogether.tsx @@ -0,0 +1,263 @@ +import { Fragment, Suspense, useContext, useEffect, useState } from 'react'; +import { useLocation, useParams, useSearchParams } from 'react-router-dom'; +import { styled } from 'styled-components'; + +import { getApi } from '../apis/getApi'; +import SeeTogetherNotFilledSVG from '../assets/seeTogetherBtn_notFilled.svg'; +import Button from '../components/common/Button'; +import Flex from '../components/common/Flex'; +import Space from '../components/common/Space'; +import Text from '../components/common/Text'; +import PinsOfTopic from '../components/PinsOfTopic'; +import PullPin from '../components/PullPin'; +import PinsOfTopicSkeleton from '../components/Skeletons/PinsOfTopicSkeleton'; +import { LAYOUT_PADDING, SIDEBAR } from '../constants'; +import { CoordinatesContext } from '../context/CoordinatesContext'; +import { MarkerContext } from '../context/MarkerContext'; +import { SeeTogetherContext } from '../context/SeeTogetherContext'; +import useNavigator from '../hooks/useNavigator'; +import useResizeMap from '../hooks/useResizeMap'; +import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; +import useTags from '../hooks/useTags'; +import { PinProps } from '../types/Pin'; +import { TopicDetailProps } from '../types/Topic'; +import PinDetail from './PinDetail'; + +function SeeTogether() { + const accessToken = localStorage.getItem('userToken'); + + const { topicId } = useParams(); + const { routePage } = useNavigator(); + const [searchParams, _] = useSearchParams(); + const location = useLocation(); + + const [isOpen, setIsOpen] = useState(true); + const [isEditPinDetail, setIsEditPinDetail] = useState(false); + const [selectedPinId, setSelectedPinId] = useState(null); + const [topicDetails, setTopicDetails] = useState( + null, + ); + + const { tags, setTags, onClickInitTags, onClickCreateTopicWithTags } = + useTags(); + const { setCoordinates } = useContext(CoordinatesContext); + const { width } = useSetLayoutWidth(SIDEBAR); + const { seeTogetherTopics } = useContext(SeeTogetherContext); + const { markers, removeMarkers, removeInfowindows } = + useContext(MarkerContext); + useSetNavbarHighlight('seeTogether'); + useResizeMap(); + + const goToHome = () => { + routePage('/'); + }; + + const getAndSetDataFromServer = async () => { + if (topicId === '-1' || !topicId) return; + + const requestTopicIds = accessToken + ? topicId + : seeTogetherTopics?.join(','); + + const topics = await getApi( + `/topics/ids?ids=${requestTopicIds}`, + ); + + setTopicDetails([...topics]); + setCoordinatesTopicDetailWithHashMap(topics); + }; + + const setCoordinatesTopicDetailWithHashMap = (topics: TopicDetailProps[]) => { + if (topicId === '-1' || !topicId) return; + + const newCoordinates: any = []; + + topics.forEach((topic: TopicDetailProps) => { + topic.pins.forEach((pin: PinProps) => { + newCoordinates.push({ + id: pin.id, + topicId: topic.id, + pinName: pin.name, + latitude: pin.latitude, + longitude: pin.longitude, + }); + }); + }); + + setCoordinates(newCoordinates); + }; + + const togglePinDetail = () => { + setIsOpen(!isOpen); + }; + + useEffect(() => { + const queryParams = new URLSearchParams(location.search); + + if (queryParams.has('pinDetail')) { + setSelectedPinId(Number(queryParams.get('pinDetail'))); + setIsOpen(true); + return; + } + + setSelectedPinId(null); + }, [searchParams]); + + useEffect(() => { + getAndSetDataFromServer(); + }, [topicId]); + + useEffect(() => { + setTags([]); + + if (markers && markers.length > 0) { + removeMarkers(); + removeInfowindows(); + } + }, []); + + if (!seeTogetherTopics || !topicId) return <>; + + if (seeTogetherTopics.length === 0 || topicId === '-1') { + return ( + + + + + + 버튼을 눌러 지도를 추가해보세요. + + + + + + + ); + } + + return ( + + + {tags.length > 0 && ( + + )} + }> + {topicDetails?.map((topicDetail, idx) => ( + + topicDetail.id) + .join(',')} + topicId={String(topicDetail.id)} + topicDetail={topicDetail} + setSelectedPinId={setSelectedPinId} + setIsEditPinDetail={setIsEditPinDetail} + setTopicsFromServer={getAndSetDataFromServer} + /> + {idx !== topicDetails.length - 1 ? : null} + + ))} + + + + + {selectedPinId && ( + <> + + ◀ + + + + + + )} + + ); +} + +const WrapperWhenEmpty = styled.section<{ width: '372px' | '100vw' }>` + width: ${({ width }) => `calc(${width} - 40px)`}; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + margin: 0 auto; +`; + +const Wrapper = styled.section<{ + width: 'calc(100vw - 40px)' | 'calc(372px - 40px)'; + $selectedPinId: number | null; +}>` + display: flex; + flex-direction: column; + width: ${({ width }) => width}; + margin: 0 auto; + + @media (max-width: 1076px) { + width: ${({ $selectedPinId }) => ($selectedPinId ? '49vw' : '50vw')}; + margin: ${({ $selectedPinId }) => $selectedPinId && '0'}; + } + + @media (max-width: 744px) { + width: 100%; + } +`; + +const PinDetailWrapper = styled.div` + &.collapsedPinDetail { + z-index: -1; + } +`; + +const ToggleButton = styled.button<{ + $isCollapsed: boolean; +}>` + position: absolute; + top: 50%; + left: 744px; + transform: translateY(-50%); + z-index: 1; + height: 80px; + background-color: #fff; + padding: 12px; + border-radius: 4px; + box-shadow: 0px 1px 5px rgba(0, 0, 0, 0.2); + cursor: pointer; + + ${(props) => + props.$isCollapsed && + ` + transform: rotate(180deg); + top: 45%; + left: 372px; + z-index: 1; + `} + + &:hover { + background-color: #f5f5f5; + } + + @media (max-width: 1076px) { + display: none; + } +`; + +export default SeeTogether; diff --git a/frontend/src/pages/SelectedTopic.tsx b/frontend/src/pages/SelectedTopic.tsx index a640b11c..45e63dd8 100644 --- a/frontend/src/pages/SelectedTopic.tsx +++ b/frontend/src/pages/SelectedTopic.tsx @@ -1,162 +1,85 @@ -import { - Fragment, - lazy, - Suspense, - useContext, - useEffect, - useState, -} from 'react'; +import { lazy, Suspense, useContext, useEffect, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { styled } from 'styled-components'; import { getApi } from '../apis/getApi'; -import SeeTogetherNotFilledSVG from '../assets/seeTogetherBtn_notFilled.svg'; -import Button from '../components/common/Button'; -import Flex from '../components/common/Flex'; import Space from '../components/common/Space'; -import Text from '../components/common/Text'; import PullPin from '../components/PullPin'; import PinsOfTopicSkeleton from '../components/Skeletons/PinsOfTopicSkeleton'; import { LAYOUT_PADDING, SIDEBAR } from '../constants'; import { CoordinatesContext } from '../context/CoordinatesContext'; -import { SeeTogetherContext } from '../context/SeeTogetherContext'; -import { TagContext } from '../context/TagContext'; -import useNavigator from '../hooks/useNavigator'; +import useResizeMap from '../hooks/useResizeMap'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import useMapStore from '../store/mapInstance'; +import useTags from '../hooks/useTags'; import { PinProps } from '../types/Pin'; import { TopicDetailProps } from '../types/Topic'; import PinDetail from './PinDetail'; const PinsOfTopic = lazy(() => import('../components/PinsOfTopic')); -const getAvailableWidth = (sidebarWidth: number = 372) => - window.innerWidth - sidebarWidth; - -const getAvailableHeight = () => window.innerHeight; - function SelectedTopic() { const { topicId } = useParams(); const [searchParams, _] = useSearchParams(); - const [topicDetails, setTopicDetails] = useState( - null, - ); + const [topicDetail, setTopicDetail] = useState(null); const [selectedPinId, setSelectedPinId] = useState(null); const [isOpen, setIsOpen] = useState(true); const [isEditPinDetail, setIsEditPinDetail] = useState(false); - const { routePage } = useNavigator(); const { setCoordinates } = useContext(CoordinatesContext); - const { tags, setTags } = useContext(TagContext); const { width } = useSetLayoutWidth(SIDEBAR); - const { navbarHighlights: __ } = useSetNavbarHighlight( - topicId && topicId.split(',').length > 1 ? 'seeTogether' : 'home', - ); - const { seeTogetherTopics, setSeeTogetherTopics } = - useContext(SeeTogetherContext); - const { mapInstance } = useMapStore((state) => state); - - const resizeMap = () => { - if (!mapInstance) return; - - mapInstance.resize(getAvailableWidth(372), getAvailableHeight()); - }; - - const goToHome = () => { - routePage('/'); - }; + const { tags, setTags, onClickInitTags, onClickCreateTopicWithTags } = + useTags(); + useSetNavbarHighlight('none'); + useResizeMap(); const getAndSetDataFromServer = async () => { - if (topicId === '-1') return; - - const data = await getApi(`/topics/ids?ids=${topicId}`); - - const topicHashmap = new Map([]); + const topicInArray = await getApi( + `/topics/ids?ids=${topicId}`, + ); + const topic = topicInArray[0]; - setTopicDetails(data); + setTopicDetail(topic); + setCoordinatesTopicDetail(topic); + }; - // 각 topic의 pin들의 좌표를 가져옴 + const setCoordinatesTopicDetail = (topic: TopicDetailProps) => { const newCoordinates: any = []; - data.forEach((topic: TopicDetailProps) => { - topic.pins.forEach((pin: PinProps) => { - newCoordinates.push({ - id: pin.id, - topicId: topic.id, - pinName: pin.name, - latitude: pin.latitude, - longitude: pin.longitude, - }); + topic.pins.forEach((pin: PinProps) => { + newCoordinates.push({ + id: pin.id, + topicId, + pinName: pin.name, + latitude: pin.latitude, + longitude: pin.longitude, }); }); setCoordinates(newCoordinates); - - data.forEach((topicDetailFromData: TopicDetailProps) => - topicHashmap.set(`${topicDetailFromData.id}`, topicDetailFromData), - ); - - const topicDetailFromData = topicId - ?.split(',') - .map((number) => topicHashmap.get(number)) as TopicDetailProps[]; - - if (!topicDetailFromData) return; - - setTopicDetails([...topicDetailFromData]); }; - const onClickConfirm = () => { - routePage('/new-topic', tags.map((tag) => tag.id).join(',')); - }; - - const onTagCancel = () => { - setTags([]); + const togglePinDetail = () => { + setIsOpen(!isOpen); }; useEffect(() => { const queryParams = new URLSearchParams(location.search); + if (queryParams.has('pinDetail')) { setSelectedPinId(Number(queryParams.get('pinDetail'))); - } else { - setSelectedPinId(null); + setIsOpen(true); + return; } - setIsOpen(true); + setSelectedPinId(null); }, [searchParams]); useEffect(() => { getAndSetDataFromServer(); setTags([]); - if (window.innerWidth > 1180) resizeMap(); }, []); - const togglePinDetail = () => { - setIsOpen(!isOpen); - }; - - if (!seeTogetherTopics) return <>; - - if (seeTogetherTopics.length === 0 && topicId === '-1') { - return ( - - - - - - 버튼을 눌러 지도를 추가해보세요. - - - - - - - ); - } - - if (!topicDetails) return <>; - if (!topicId) return <>; + if (!topicId || !topicDetail) return <>; return ( )} }> - {topicDetails.map((topicDetail, idx) => ( - - - {idx !== topicDetails.length - 1 ? : <>} - - ))} + @@ -252,7 +170,7 @@ const ToggleButton = styled.button<{ props.$isCollapsed && ` transform: rotate(180deg); - top:45%; + top: 45%; left: 372px; z-index: 1; `} @@ -266,15 +184,4 @@ const ToggleButton = styled.button<{ } `; -const WrapperWhenEmpty = styled.section<{ width: '372px' | '100vw' }>` - width: ${({ width }) => `calc(${width} - 40px)`}; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - - margin: 0 auto; -`; - export default SelectedTopic; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index a6804988..dd9b8268 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -17,6 +17,7 @@ const KakaoRedirect = lazy(() => import('./pages/KakaoRedirect')); const Profile = lazy(() => import('./pages/Profile')); const AskLogin = lazy(() => import('./pages/AskLogin')); const Bookmark = lazy(() => import('./pages/Bookmark')); +const SeeTogether = lazy(() => import('./pages/SeeTogether')); interface routeElement { path: string; @@ -143,6 +144,15 @@ const routes: routeElement[] = [ element: , withAuth: false, }, + { + path: '/see-together/:topicId', + element: ( + + + + ), + withAuth: false, + }, ], }, ];