diff --git a/frontend/.prettierrc.js b/frontend/.prettierrc.js index e340799c7..409c297ec 100644 --- a/frontend/.prettierrc.js +++ b/frontend/.prettierrc.js @@ -1,3 +1,4 @@ module.exports = { + printWidth: 100, singleQuote: true, }; diff --git a/frontend/src/apis/new/index.ts b/frontend/src/apis/new/index.ts index d129b0ed4..c3a8a0d8f 100644 --- a/frontend/src/apis/new/index.ts +++ b/frontend/src/apis/new/index.ts @@ -1,7 +1,19 @@ -import { TopicCardProps } from '../../types/Topic'; +import { ClusteredCoordinates } from '../../pages/SelectedTopic/types'; +import { TopicCardProps, TopicDetailProps } from '../../types/Topic'; import { http } from './http'; export const getTopics = (url: string) => http.get(url); export const getProfile = () => http.get('/members/my/topics'); + +export const getTopicDetail = (topicId: string) => + http.get(`/topics/ids?ids=${topicId}`); + +export const getClusteredCoordinates = ( + topicId: string, + distanceOfPinSize: number, +) => + http.get( + `/topics/clusters?ids=${topicId}&image-diameter=${distanceOfPinSize}`, + ); diff --git a/frontend/src/hooks/useTags.ts b/frontend/src/hooks/useTags.ts index fa3e2ad6d..7a1e4bf16 100644 --- a/frontend/src/hooks/useTags.ts +++ b/frontend/src/hooks/useTags.ts @@ -1,9 +1,13 @@ -import { useContext } from 'react'; +import { useContext, useEffect } from 'react'; import { TagContext } from '../context/TagContext'; import useNavigator from './useNavigator'; -const useTags = () => { +interface Props { + isInitTags: boolean; +} + +const useTags = ({ isInitTags }: Props) => { const { tags, setTags } = useContext(TagContext); const { routePage } = useNavigator(); @@ -15,7 +19,11 @@ const useTags = () => { setTags([]); }; - return { tags, setTags, onClickInitTags, onClickCreateTopicWithTags }; + useEffect(() => { + if (isInitTags) setTags([]); + }, []); + + return { tags, onClickInitTags, onClickCreateTopicWithTags }; }; export default useTags; diff --git a/frontend/src/pages/SelectedTopic.tsx b/frontend/src/pages/SelectedTopic.tsx deleted file mode 100644 index 593cea0ac..000000000 --- a/frontend/src/pages/SelectedTopic.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { lazy, Suspense, useContext, useEffect, useRef, useState } from 'react'; -import { useParams, useSearchParams } from 'react-router-dom'; -import { styled } from 'styled-components'; - -import { getApi } from '../apis/getApi'; -import Space from '../components/common/Space'; -import PullPin from '../components/PullPin'; -import PinsOfTopicSkeleton from '../components/Skeletons/PinsOfTopicSkeleton'; -import { LAYOUT_PADDING, PIN_SIZE, SIDEBAR } from '../constants'; -import { CoordinatesContext } from '../context/CoordinatesContext'; -import useRealDistanceOfPin from '../hooks/useRealDistanceOfPin'; -import useResizeMap from '../hooks/useResizeMap'; -import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; -import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight'; -import useTags from '../hooks/useTags'; -import useMapStore from '../store/mapInstance'; -import { TopicDetailProps } from '../types/Topic'; -import PinDetail from './PinDetail'; - -const PinsOfTopic = lazy(() => import('../components/PinsOfTopic')); - -function SelectedTopic() { - const { Tmapv3 } = window; - const { topicId } = useParams(); - const [searchParams, _] = useSearchParams(); - const [topicDetail, setTopicDetail] = useState(null); - const [selectedPinId, setSelectedPinId] = useState(null); - const [isOpen, setIsOpen] = useState(true); - const [isEditPinDetail, setIsEditPinDetail] = useState(false); - const { setCoordinates } = useContext(CoordinatesContext); - const { width } = useSetLayoutWidth(SIDEBAR); - const zoomTimerIdRef = useRef(null); - const dragTimerIdRef = useRef(null); - const { mapInstance } = useMapStore((state) => state); - const { getDistanceOfPin } = useRealDistanceOfPin(); - - const { tags, setTags, onClickInitTags, onClickCreateTopicWithTags } = - useTags(); - useSetNavbarHighlight('none'); - useResizeMap(); - - const getAndSetDataFromServer = async () => { - const topicInArray = await getApi( - `/topics/ids?ids=${topicId}`, - ); - const topic = topicInArray[0]; - - setTopicDetail(topic); - }; - - const setClusteredCoordinates = async () => { - if (!topicDetail || !mapInstance) return; - - const newCoordinates: any = []; - const distanceOfPinSize = getDistanceOfPin(mapInstance); - - const diameterPins = await getApi( - `/topics/clusters?ids=${topicId}&image-diameter=${distanceOfPinSize}`, - ); - - diameterPins.forEach((clusterOrPin: any, idx: number) => { - newCoordinates.push({ - topicId, - id: clusterOrPin.pins[0].id || `cluster ${idx}`, - pinName: clusterOrPin.pins[0].name, - latitude: clusterOrPin.latitude, - longitude: clusterOrPin.longitude, - pins: clusterOrPin.pins, - }); - }); - - setCoordinates(newCoordinates); - }; - - const setPrevCoordinates = () => { - setCoordinates((prev) => [...prev]); - }; - - const adjustMapDirection = () => { - if (!mapInstance) return; - - mapInstance.setBearing(0); - mapInstance.setPitch(0); - }; - - useEffect(() => { - getAndSetDataFromServer(); - setTags([]); - }, []); - - useEffect(() => { - setClusteredCoordinates(); - - const onDragEnd = (evt: evt) => { - if (dragTimerIdRef.current) { - clearTimeout(dragTimerIdRef.current); - } - - dragTimerIdRef.current = setTimeout(() => { - setPrevCoordinates(); - adjustMapDirection(); - }, 100); - }; - const onZoomEnd = (evt: evt) => { - if (zoomTimerIdRef.current) { - clearTimeout(zoomTimerIdRef.current); - } - - zoomTimerIdRef.current = setTimeout(() => { - setClusteredCoordinates(); - adjustMapDirection(); - }, 100); - }; - - if (!mapInstance) return; - - mapInstance.on('DragEnd', onDragEnd); - mapInstance.on('ZoomEnd', onZoomEnd); - - return () => { - mapInstance.off('DragEnd', onDragEnd); - mapInstance.off('ZoomEnd', onZoomEnd); - }; - }, [topicDetail]); - - useEffect(() => { - const queryParams = new URLSearchParams(location.search); - - if (queryParams.has('pinDetail')) { - setSelectedPinId(Number(queryParams.get('pinDetail'))); - setIsOpen(true); - return; - } - - setSelectedPinId(null); - }, [searchParams]); - - if (!topicId || !topicDetail) return <>; - - return ( - - - {tags.length > 0 && ( - - )} - }> - - - - - - {selectedPinId && ( - <> - { - setIsOpen(!isOpen); - }} - aria-label={`장소 상세 설명 버튼 ${isOpen ? '닫기' : '열기'}`} - > - ◀ - - - - - - )} - - ); -} - -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 SelectedTopic; diff --git a/frontend/src/pages/SelectedTopic/SelectedTopic.page.tsx b/frontend/src/pages/SelectedTopic/SelectedTopic.page.tsx new file mode 100644 index 000000000..c243f2537 --- /dev/null +++ b/frontend/src/pages/SelectedTopic/SelectedTopic.page.tsx @@ -0,0 +1,145 @@ +import { lazy, Suspense, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { styled } from 'styled-components'; + +import Space from '../../components/common/Space'; +import PullPin from '../../components/PullPin'; +import PinsOfTopicSkeleton from '../../components/Skeletons/PinsOfTopicSkeleton'; +import { LAYOUT_PADDING, SIDEBAR } from '../../constants'; +import useResizeMap from '../../hooks/useResizeMap'; +import useSetLayoutWidth from '../../hooks/useSetLayoutWidth'; +import useSetNavbarHighlight from '../../hooks/useSetNavbarHighlight'; +import useTags from '../../hooks/useTags'; +import PinDetail from '../PinDetail'; +import useClusterCoordinates from './hooks/useClusterCoordinates'; +import useHandleMapInteraction from './hooks/useHandleMapInteraction'; +import useSetSelectedPinId from './hooks/useSetSelectedPinId'; +import useTopicDetailQuery from './hooks/useTopicDetailQuery'; + +const PinsOfTopic = lazy(() => import('../../components/PinsOfTopic')); + +function SelectedTopic() { + const { topicId } = useParams() as { topicId: string }; + const [isEditPinDetail, setIsEditPinDetail] = useState(false); + const { width } = useSetLayoutWidth(SIDEBAR); + const { tags, onClickInitTags, onClickCreateTopicWithTags } = useTags({ isInitTags: true }); + const { isDoubleSidebarOpen, selectedPinId, setIsDoubleSidebarOpen, setSelectedPinId } = useSetSelectedPinId(); + const { data: topicDetail, refetch: getTopicDetail } = useTopicDetailQuery(topicId); + const setClusteredCoordinates = useClusterCoordinates(topicId); + + useHandleMapInteraction({ + topicId, + onAfterInteraction: setClusteredCoordinates, + }); + useSetNavbarHighlight('none'); + useResizeMap(); + + if (!topicId || !topicDetail) return <>; + + return ( + + + {tags.length > 0 && ( + + )} + }> + + + + + + {selectedPinId && ( + <> + { + setIsDoubleSidebarOpen(!isDoubleSidebarOpen); + }} + aria-label={`장소 상세 설명 버튼 ${isDoubleSidebarOpen ? '닫기' : '열기'}`} + > + ◀ + + + + + + )} + + ); +} + +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 SelectedTopic; diff --git a/frontend/src/pages/SelectedTopic/hooks/useClusterCoordinates.ts b/frontend/src/pages/SelectedTopic/hooks/useClusterCoordinates.ts new file mode 100644 index 000000000..77b1361bf --- /dev/null +++ b/frontend/src/pages/SelectedTopic/hooks/useClusterCoordinates.ts @@ -0,0 +1,48 @@ +import { useContext } from 'react'; + +import { getClusteredCoordinates } from '../../../apis/new'; +import { CoordinatesContext } from '../../../context/CoordinatesContext'; +import useRealDistanceOfPin from '../../../hooks/useRealDistanceOfPin'; +import useMapStore from '../../../store/mapInstance'; +import { ClusteredCoordinates } from '../types'; + +interface ClusteredCoordinatesWithTopicId extends ClusteredCoordinates { + topicId: string; + id: string; + pinName: string; +} + +const useClusterCoordinates = (topicId: string) => { + const { mapInstance } = useMapStore((state) => state); + const { getDistanceOfPin } = useRealDistanceOfPin(); + const { setCoordinates } = useContext(CoordinatesContext); + + const setClusteredCoordinates = async () => { + if (!mapInstance) return; + + const newCoordinates: ClusteredCoordinatesWithTopicId[] = []; + const diameterOfPinSize = getDistanceOfPin(mapInstance); + + const responseData = await getClusteredCoordinates( + topicId, + diameterOfPinSize, + ); + + responseData.forEach((clusterOrPin: any, idx: number) => { + newCoordinates.push({ + topicId, + id: clusterOrPin.pins[0].id || `cluster ${idx}`, + pinName: clusterOrPin.pins[0].name, + latitude: clusterOrPin.latitude, + longitude: clusterOrPin.longitude, + pins: clusterOrPin.pins, + }); + }); + + setCoordinates(newCoordinates); + }; + + return setClusteredCoordinates; +}; + +export default useClusterCoordinates; diff --git a/frontend/src/pages/SelectedTopic/hooks/useHandleMapInteraction.ts b/frontend/src/pages/SelectedTopic/hooks/useHandleMapInteraction.ts new file mode 100644 index 000000000..562b12263 --- /dev/null +++ b/frontend/src/pages/SelectedTopic/hooks/useHandleMapInteraction.ts @@ -0,0 +1,65 @@ +import { useContext, useEffect, useRef } from 'react'; + +import { CoordinatesContext } from '../../../context/CoordinatesContext'; +import useMapStore from '../../../store/mapInstance'; + +interface Props { + topicId: string; + onAfterInteraction: () => void; +} + +const useHandleMapInteraction = ({ topicId, onAfterInteraction }: Props) => { + const { mapInstance } = useMapStore((state) => state); + + const zoomTimerIdRef = useRef(null); + const dragTimerIdRef = useRef(null); + const { setCoordinates } = useContext(CoordinatesContext); + + const setPrevCoordinates = () => { + setCoordinates((prev) => [...prev]); + }; + + const adjustMapDirection = () => { + if (!mapInstance) return; + + mapInstance.setBearing(0); + mapInstance.setPitch(0); + }; + + useEffect(() => { + onAfterInteraction(); + + const onDragEnd = (evt: evt) => { + if (dragTimerIdRef.current) { + clearTimeout(dragTimerIdRef.current); + } + + dragTimerIdRef.current = setTimeout(() => { + setPrevCoordinates(); + adjustMapDirection(); + }, 100); + }; + const onZoomEnd = (evt: evt) => { + if (zoomTimerIdRef.current) { + clearTimeout(zoomTimerIdRef.current); + } + + zoomTimerIdRef.current = setTimeout(() => { + onAfterInteraction(); + adjustMapDirection(); + }, 100); + }; + + if (!mapInstance) return; + + mapInstance.on('DragEnd', onDragEnd); + mapInstance.on('ZoomEnd', onZoomEnd); + + return () => { + mapInstance.off('DragEnd', onDragEnd); + mapInstance.off('ZoomEnd', onZoomEnd); + }; + }, [topicId, mapInstance]); +}; + +export default useHandleMapInteraction; diff --git a/frontend/src/pages/SelectedTopic/hooks/useSetSelectedPinId.ts b/frontend/src/pages/SelectedTopic/hooks/useSetSelectedPinId.ts new file mode 100644 index 000000000..6f325ec08 --- /dev/null +++ b/frontend/src/pages/SelectedTopic/hooks/useSetSelectedPinId.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +const useSetSelectedPinId = () => { + const [searchParams, _] = useSearchParams(); + const [isDoubleSidebarOpen, setIsDoubleSidebarOpen] = useState(true); + const [selectedPinId, setSelectedPinId] = useState(null); + + useEffect(() => { + const queryParams = new URLSearchParams(location.search); + + if (queryParams.has('pinDetail')) { + setSelectedPinId(Number(queryParams.get('pinDetail'))); + setIsDoubleSidebarOpen(true); + return; + } + + setSelectedPinId(null); + }, [searchParams]); + + return { isDoubleSidebarOpen, selectedPinId, setIsDoubleSidebarOpen, setSelectedPinId }; +}; + +export default useSetSelectedPinId; diff --git a/frontend/src/pages/SelectedTopic/hooks/useTopicDetailQuery.ts b/frontend/src/pages/SelectedTopic/hooks/useTopicDetailQuery.ts new file mode 100644 index 000000000..a6ab0a715 --- /dev/null +++ b/frontend/src/pages/SelectedTopic/hooks/useTopicDetailQuery.ts @@ -0,0 +1,15 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { getTopicDetail } from '../../../apis/new'; + +const useTopicDetailQuery = (topicId: string) => { + const { data, refetch, ...rest } = useSuspenseQuery({ + queryKey: ['topicDetail', topicId], + queryFn: () => getTopicDetail(topicId), + select: (response) => response[0], + }); + + return { data, refetch, ...rest }; +}; + +export default useTopicDetailQuery; diff --git a/frontend/src/pages/SelectedTopic/types/index.ts b/frontend/src/pages/SelectedTopic/types/index.ts new file mode 100644 index 000000000..ff3f72be1 --- /dev/null +++ b/frontend/src/pages/SelectedTopic/types/index.ts @@ -0,0 +1,11 @@ +export interface ClusteredCoordinates { + latitude: number; + longitude: number; + pins: ClusteredPin[]; +} + +interface ClusteredPin { + id: number; + name: string; + topicId: number; +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 0f9715716..a722e54d9 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -8,7 +8,9 @@ import NotFound from './pages/NotFound'; import RootPage from './pages/RootPage'; import Search from './pages/Search'; -const SelectedTopic = lazy(() => import('./pages/SelectedTopic')); +const SelectedTopic = lazy( + () => import('./pages/SelectedTopic/SelectedTopic.page'), +); const NewPin = lazy(() => import('./pages/NewPin')); const NewTopic = lazy(() => import('./pages/NewTopic')); const SeeAllBestTopics = lazy(() => import('./pages/SeeAllBestTopics'));