diff --git a/backend/src/controllers/channelController.js b/backend/src/controllers/channelController.js index 619ad529..d83858fc 100644 --- a/backend/src/controllers/channelController.js +++ b/backend/src/controllers/channelController.js @@ -1,6 +1,7 @@ import { addGuestService, createChannelService, + deleteChannelService, getChannelByIdService, getChannelGuestInfoService, getUserChannels, @@ -109,3 +110,25 @@ export const getUserChannelsController = async (req, res) => { return res.status(500).json(new ErrorResponseDto({ message: 'Server error occurred' })); } }; + +/** + * @description 채널 삭제 컨트롤러 + */ +export const deleteChannelController = async (req, res) => { + const { id } = req.params; + + try { + const result = await deleteChannelService(id); + + if (!result) { + return res.status(404).json(new ErrorResponseDto({ message: 'Channel not found' })); + } + + return res + .status(200) + .json(new ResponseDto({ resultMsg: 'Channel deleted successfully', data: { id } })); + } catch (err) { + console.error(err); + return res.status(500).json(new ErrorResponseDto({ message: 'Server error occurred' })); + } +}; diff --git a/backend/src/repositories/channelRepository.js b/backend/src/repositories/channelRepository.js index 647c6e49..34862e74 100644 --- a/backend/src/repositories/channelRepository.js +++ b/backend/src/repositories/channelRepository.js @@ -155,3 +155,17 @@ export const getChannelsByUserIdFromDB = async userId => { throw error; } }; + +export const deleteChannelByIdFromDB = async id => { + try { + const query = 'DELETE FROM "main"."channel" WHERE id = $1 RETURNING id'; + const values = [id]; + + const result = await pool.query(query, values); + + return result.rowCount > 0; + } catch (error) { + console.error('Database error:', error); + throw error; + } +}; diff --git a/backend/src/routes/channelRouter.js b/backend/src/routes/channelRouter.js index 0725f449..cbc0ae00 100644 --- a/backend/src/routes/channelRouter.js +++ b/backend/src/routes/channelRouter.js @@ -3,6 +3,7 @@ import { body, param } from 'express-validator'; import { addGuestController, createChannelController, + deleteChannelController, getChannelGuestInfoController, getChannelInfoController, getUserChannelsController, @@ -190,3 +191,35 @@ channelRouter.get( validationMiddleware, getUserChannelsController, ); + +// 채널 삭제 API 경로 +/** + * @swagger + * paths: + * /channel/{id}: + * delete: + * summary: '채널 삭제 API' + * description: '채널 ID를 사용하여 특정 채널을 삭제합니다.' + * tags: [Channel] + * parameters: + * - name: 'id' + * in: 'path' + * required: true + * schema: + * type: 'string' + * description: '삭제할 채널의 고유 ID' + * responses: + * 200: + * description: '채널 삭제 성공' + * 404: + * description: '채널을 찾을 수 없음' + * 500: + * description: '서버 오류' + */ +channelRouter.delete( + '/:id', + [param('id').notEmpty().withMessage('Channel ID is required')], + authenticateJWT, + validationMiddleware, + deleteChannelController, +); diff --git a/backend/src/services/channelService.js b/backend/src/services/channelService.js index d6f1a2c0..e7b05fe4 100644 --- a/backend/src/services/channelService.js +++ b/backend/src/services/channelService.js @@ -1,5 +1,6 @@ import { createChannelInDB, + deleteChannelByIdFromDB, getChannelInfoByIdInDB, getChannelsByUserIdFromDB, getChannelWithGuestsByIdFromDB, @@ -105,3 +106,16 @@ export const getUserChannels = async userId => { throw new Error('Failed to fetch channels', error); } }; + +/** + * @description 채널 삭제 서비스 + * @param {string} id - 삭제할 채널의 ID + * @returns {boolean} 삭제 성공 여부 + */ +export const deleteChannelService = async id => { + try { + return await deleteChannelByIdFromDB(id); + } catch (error) { + throw new Error('Failed to delete channel', error); + } +}; diff --git a/frontend/src/App.css b/frontend/src/App.css index 77787acb..a2619de8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -3,6 +3,14 @@ font-family: Pretendard, 'Pretendard Variable', sans-serif !important; } +@supports (-webkit-touch-callout: none) { + #root { + touch-action: manipulation; /* Safari 호환 옵션 */ + overscroll-behavior: none; + } +} + + .logo { height: 6em; padding: 1.5em; diff --git a/frontend/src/api/channel.api.ts b/frontend/src/api/channel.api.ts index d477ccda..6c7d961f 100644 --- a/frontend/src/api/channel.api.ts +++ b/frontend/src/api/channel.api.ts @@ -7,6 +7,7 @@ import { getChannelResEntity, getUserChannelsResEntity, getGuestResEntity, + deleteChannelResEntity, } from '@/api/dto/channel.dto.ts'; import { getApiClient } from '@/api/client.api.ts'; @@ -136,3 +137,27 @@ export const getGuestInfo = ( }; return new Promise(promiseFn); }; + +export const deleteChannel = (channelId: string): Promise> => { + const promiseFn = ( + fnResolve: (value: ResponseDto) => void, + fnReject: (reason?: any) => void, + ) => { + const apiClient = getApiClient(); + apiClient + .delete(`/channel/${channelId}`) + .then(res => { + if (res.status !== 200) { + console.error(res); + fnReject(`msg.${res}`); + } else { + fnResolve(new ResponseDto(res.data)); + } + }) + .catch(err => { + console.error(err); + fnReject('msg.RESULT_FAILED'); + }); + }; + return new Promise(promiseFn); +}; diff --git a/frontend/src/api/dto/channel.dto.ts b/frontend/src/api/dto/channel.dto.ts index b38e3774..1b7161bb 100644 --- a/frontend/src/api/dto/channel.dto.ts +++ b/frontend/src/api/dto/channel.dto.ts @@ -113,3 +113,7 @@ export class getGuestResEntity { guest: guestEntity | undefined; } + +export class deleteChannelResEntity { + id: string | undefined; +} diff --git a/frontend/src/assets/footprint.svg b/frontend/src/assets/footprint.svg index 6f631378..52e50feb 100644 --- a/frontend/src/assets/footprint.svg +++ b/frontend/src/assets/footprint.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + \ No newline at end of file diff --git a/frontend/src/component/authmodal/AuthModal.tsx b/frontend/src/component/authmodal/AuthModal.tsx index 79493a6b..1778664f 100644 --- a/frontend/src/component/authmodal/AuthModal.tsx +++ b/frontend/src/component/authmodal/AuthModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Modal } from '@/component/common/modal/Modal'; import { doLogin, doRegister } from '@/api/auth.api.ts'; import { saveLocalData } from '@/utils/common/manageLocalData.ts'; @@ -91,6 +91,10 @@ export const AuthModal = (props: IAuthModalProps) => { }); }; + useEffect(() => { + if (!props.isOpen) switchToLogin(); + }, [props.isOpen]); + return ( {modalType === 'login' ? ( diff --git a/frontend/src/component/bottomsheet/BottomSheet.tsx b/frontend/src/component/bottomsheet/BottomSheet.tsx index 209fea97..00c901c1 100644 --- a/frontend/src/component/bottomsheet/BottomSheet.tsx +++ b/frontend/src/component/bottomsheet/BottomSheet.tsx @@ -1,7 +1,11 @@ import React, { useState, useRef } from 'react'; -import { MdClear } from 'react-icons/md'; +import classNames from 'classnames'; +import { SetCurrentLocationButton } from '../setCurrentLocationButton/SetCurrentLocationButton'; interface IBottomSheetProps { + map: naver.maps.Map | null; + lat: number | null; + lng: number | null; minHeight: number; maxHeight: number; backgroundColor: string; @@ -9,6 +13,9 @@ interface IBottomSheetProps { } export const BottomSheet = ({ + map, + lat, + lng, minHeight, maxHeight, backgroundColor, @@ -52,37 +59,79 @@ export const BottomSheet = ({ window.addEventListener('mouseup', handleMouseUp); }; - const handleClose = () => { - setSheetHeight(minHeight); + const [, setScrollPosition] = useState(0); + const [touchStartY, setTouchStartY] = useState(null); + + const handleContentTouchStart = (e: React.TouchEvent) => { + setTouchStartY(e.touches[0].clientY); + }; + + const handleContentTouchMove = (e: React.TouchEvent) => { + if (touchStartY !== null) { + const deltaY = e.touches[0].clientY - touchStartY; + + const scrollableElement = e.currentTarget; // 현재 스크롤이 가능한 요소 + const newScrollPosition = scrollableElement.scrollTop - deltaY; + + scrollableElement.scrollTop = newScrollPosition; + + setTouchStartY(e.touches[0].clientY); + + setScrollPosition(newScrollPosition); + } + }; + + const handleContentTouchEnd = () => { + setTouchStartY(null); }; return ( -
+ <>
-
+
- -
- -
+
+
+
-
{children}
-
+
+ {children} +
+
+
+ ); }; diff --git a/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx b/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx index d30578aa..d70c7a78 100644 --- a/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx +++ b/frontend/src/component/canvasWithMap/canvasWithMapForView/MapCanvasForView.tsx @@ -3,23 +3,30 @@ import { ICanvasPoint, IMapCanvasViewProps, IPoint } from '@/lib/types/canvasInt import { useCanvasInteraction } from '@/hooks/useCanvasInteraction'; import { useRedrawCanvas } from '@/hooks/useRedraw'; import { ZoomSlider } from '@/component/zoomslider/ZoomSlider'; +import { ICluster, useCluster } from '@/hooks/useCluster'; +import { SetCurrentLocationButton } from '@/component/setCurrentLocationButton/SetCurrentLocationButton'; +import { DEFAULT_ZOOM, MIN_ZOOM } from '@/lib/constants/mapConstants.ts'; export const MapCanvasForView = forwardRef( - ({ lat, lng, alpha, otherLocations, guests, width, height }: IMapCanvasViewProps, ref) => { + ( + { lat, lng, alpha, otherLocations, guests, width, height, isMain }: IMapCanvasViewProps, + ref, + ) => { const mapRef = useRef(null); const canvasRef = useRef(null); const [projection, setProjection] = useState(null); const [map, setMap] = useState(null); - - useImperativeHandle(ref, () => map as naver.maps.Map); + const { createClusters } = useCluster(); + const [clusters, setClusters] = useState(null); + const [center, setCenter] = useState(); useEffect(() => { if (!mapRef.current) return; const mapInstance = new naver.maps.Map(mapRef.current, { center: new naver.maps.LatLng(lat, lng), - zoom: 10, - minZoom: 7, + zoom: DEFAULT_ZOOM, + minZoom: MIN_ZOOM, maxBounds: new naver.maps.LatLngBounds( new naver.maps.LatLng(33.0, 124.5), new naver.maps.LatLng(38.9, 131.9), @@ -33,7 +40,9 @@ export const MapCanvasForView = forwardRef { mapInstance.destroy(); }; - }, [lat, lng]); + }, []); + + useImperativeHandle(ref, () => map as naver.maps.Map); const latLngToCanvasPoint = (latLng: IPoint): ICanvasPoint | null => { if (!map || !projection || !canvasRef.current) return null; @@ -66,6 +75,7 @@ export const MapCanvasForView = forwardRef { + const updateClusters = () => { + if (map && guests && guests.length > 0) { + const createdClusters = guests + .map(guest => + createClusters([guest.startPoint, guest.endPoint], guest.markerStyle, map), + ) + .flat(); + + setClusters(createdClusters); + } + }; + + const handleCenterChanged = () => { + if (map) { + const currentCenter = map.getCenter(); + const point = { lat: currentCenter.x, lng: currentCenter.y }; + setCenter(point); + } + }; + + // 컴포넌트가 처음 마운트될 때 즉시 실행 + updateClusters(); + + const intervalId = setInterval(() => { + updateClusters(); + handleCenterChanged(); + }, 100); + + return () => clearInterval(intervalId); // 컴포넌트 언마운트 시 인터벌 클리어 + }, [guests, map]); + useEffect(() => { redrawCanvas(); - }, [guests, otherLocations, lat, lng, alpha, mapRef, handleWheel]); + }, [guests, otherLocations, lat, lng, alpha, clusters, handleWheel, center]); return (
+ {!isMain && }
); }, diff --git a/frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapCanvasForDraw.tsx b/frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapCanvasForDraw.tsx index 0e3fb3cd..d5b2d3c9 100644 --- a/frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapCanvasForDraw.tsx +++ b/frontend/src/component/canvasWithMap/canvasWithMapforDraw/MapCanvasForDraw.tsx @@ -13,6 +13,9 @@ import { useCanvasInteraction } from '@/hooks/useCanvasInteraction'; import { ZoomSlider } from '@/component/zoomslider/ZoomSlider'; import { useRedrawCanvas } from '@/hooks/useRedraw'; import { zoomMapView } from '@/utils/map/mapUtils'; +import { ICluster, useCluster } from '@/hooks/useCluster'; +import { MIN_ZOOM } from '@/lib/constants/mapConstants.ts'; +import { getUserLocation } from '@/hooks/getUserLocation.ts'; export const MapCanvasForDraw = ({ width, @@ -20,6 +23,8 @@ export const MapCanvasForDraw = ({ initialCenter, initialZoom, }: IMapCanvasProps) => { + const { lat, lng } = getUserLocation(); + const mapRef = useRef(null); const canvasRef = useRef(null); const [projection, setProjection] = useState(null); @@ -34,6 +39,9 @@ export const MapCanvasForDraw = ({ const { setCurrentUser } = useContext(CurrentUserContext); + const { createClusters } = useCluster(); + const [clusters, setClusters] = useState([]); + useEffect(() => { const updateUser = () => { setCurrentUser(prevUser => { @@ -63,9 +71,9 @@ export const MapCanvasForDraw = ({ if (!mapRef.current) return; const mapInstance = new naver.maps.Map(mapRef.current, { - center: new naver.maps.LatLng(initialCenter.lat, initialCenter.lng), + center: new naver.maps.LatLng(lat || initialCenter.lat, lng || initialCenter.lng), zoom: initialZoom, - minZoom: 7, + minZoom: MIN_ZOOM, maxBounds: new naver.maps.LatLngBounds( new naver.maps.LatLng(33.0, 124.5), new naver.maps.LatLng(38.9, 131.9), @@ -76,18 +84,6 @@ export const MapCanvasForDraw = ({ setMap(mapInstance); setProjection(mapInstance.getProjection()); - // TODO: 필요 없을 것으로 예상, 혹시나해서 남겨둔 것이니 필요 없다 판단되면 제거 필요 - // naver.maps.Event.addListener(mapInstance, 'zoom_changed', () => { - // setProjection(mapInstance.getProjection()); - // updateCanvasSize(); - // redrawCanvas(); - // }); - // - // naver.maps.Event.addListener(mapInstance, 'center_changed', () => { - // setProjection(mapInstance.getProjection()); - // redrawCanvas(); - // }); - return () => { mapInstance.destroy(); }; @@ -138,6 +134,7 @@ export const MapCanvasForDraw = ({ startMarker, endMarker, pathPoints, + clusters, }); const handleCanvasClick = (e: React.MouseEvent) => { @@ -238,11 +235,11 @@ export const MapCanvasForDraw = ({ }, [map]); useEffect(() => { - if (startMarker && endMarker) { - const markers = []; - - if (startMarker) markers.push(startMarker); - if (endMarker) markers.push(endMarker); + if (startMarker && endMarker && map) { + const markers = [ + { lat: startMarker.lat, lng: startMarker.lng }, + { lat: endMarker.lat, lng: endMarker.lng }, + ]; zoomMapView(map, markers); } else { @@ -257,9 +254,24 @@ export const MapCanvasForDraw = ({ } }, [startMarker, endMarker]); + useEffect(() => { + const intervalId = setInterval(() => { + if (startMarker && endMarker && map) { + const markers = [ + { lat: startMarker.lat, lng: startMarker.lng }, + { lat: endMarker.lat, lng: endMarker.lng }, + ]; + + const createdClusters = createClusters(markers, { color: '#333C4A' }, map); + setClusters(createdClusters); + } + }, 100); + + return () => clearInterval(intervalId); // 컴포넌트 언마운트 시 인터벌 클리어 + }, [startMarker, endMarker, map]); useEffect(() => { redrawCanvas(); - }, [startMarker, endMarker, pathPoints, map, undoStack, redoStack]); + }, [startMarker, endMarker, clusters, pathPoints, map, undoStack, redoStack]); return (
{ @@ -29,26 +29,12 @@ export const MapProviderForDraw = ({ width, height }: ICanvasScreenProps) => { }; }, []); - useEffect(() => { - const preventDefault = (e: TouchEvent) => { - e.preventDefault(); - }; - - document.body.style.overflow = 'hidden'; - document.addEventListener('touchmove', preventDefault, { passive: false }); - - return () => { - document.body.style.overflow = 'auto'; - document.removeEventListener('touchmove', preventDefault); - }; - }, []); - return ( ); }; diff --git a/frontend/src/component/common/alert/Alert.tsx b/frontend/src/component/common/alert/Alert.tsx index 07e4c452..47dbcdcb 100644 --- a/frontend/src/component/common/alert/Alert.tsx +++ b/frontend/src/component/common/alert/Alert.tsx @@ -8,41 +8,38 @@ interface IAlertProps { } export const AlertUI = ({ message, duration = 3000, autoClose = true, onClose }: IAlertProps) => { - const [progress, setProgress] = useState(100); + const [animateProgress, setAnimateProgress] = useState(false); useEffect(() => { if (!autoClose) return; - const start = performance.now(); + const timeout = setTimeout(() => { + setAnimateProgress(true); + }, 50); - const updateProgress = (current: number) => { - const elapsed = current - start; - const newProgress = Math.max(0, 100 - (elapsed / duration) * 100); + const timer = setTimeout(() => { + onClose?.(); + }, duration); - setProgress(newProgress); - - if (elapsed < duration) { - requestAnimationFrame(updateProgress); - } else { - onClose?.(); - } + return () => { + clearTimeout(timeout); + clearTimeout(timer); }; - - const animationFrame = requestAnimationFrame(updateProgress); - - return () => cancelAnimationFrame(animationFrame); }, [duration, autoClose, onClose]); return ( <> -
+
{message}
{autoClose ? ( -
+
) : ( diff --git a/frontend/src/component/common/modal/ModalFooter.tsx b/frontend/src/component/common/modal/ModalFooter.tsx index c255754b..acddf835 100644 --- a/frontend/src/component/common/modal/ModalFooter.tsx +++ b/frontend/src/component/common/modal/ModalFooter.tsx @@ -13,7 +13,7 @@ export const ModalFooter = (props: IModalFooterProps) => (
+); + +interface IConfirmProps { + message: string; + onConfirm: () => void; + onCancel: () => void; + type: 'confirm' | 'alert'; + confirmText?: string; + cancelText?: string; +} + +export const Confirm = (props: IConfirmProps) => { + return ( +
+
+
+ {props.message} +
+ {props.type === 'alert' ? ( + + ) : ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/component/content/Content.tsx b/frontend/src/component/content/Content.tsx index 9eb9d5f3..cb16e560 100644 --- a/frontend/src/component/content/Content.tsx +++ b/frontend/src/component/content/Content.tsx @@ -12,6 +12,7 @@ interface IContentProps { link: string; time: string; channelId: string; + onDelete?: (channelId: string) => void; } /** @@ -41,11 +42,13 @@ export const Content = (props: IContentProps) => { year: 'numeric', month: '2-digit', day: '2-digit', + timeZone: 'UTC', }); const formattedTime = new Date(props.time).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', + timeZone: 'UTC', }); const navigate = useNavigate(); const { channelInfo, setChannelInfo } = useContext(ChannelContext); @@ -60,6 +63,11 @@ export const Content = (props: IContentProps) => { console.error('Failed to get channel info:', error); } }; + + const deleteChannelItem = async () => { + props.onDelete?.(props.channelId); + }; + const goToChannelInfoPage = () => { if (channelInfo?.id) { navigate(`/channelInfo/${channelInfo.id}`); @@ -77,6 +85,10 @@ export const Content = (props: IContentProps) => { goToChannelInfoPage(); }; + const handleDelete = () => { + deleteChannelItem(); + }; + return (
{ > 공유하기 - + 삭제하기 diff --git a/frontend/src/component/header/HeaderLayout.tsx b/frontend/src/component/header/HeaderLayout.tsx index a0cdf5b1..36466eaa 100644 --- a/frontend/src/component/header/HeaderLayout.tsx +++ b/frontend/src/component/header/HeaderLayout.tsx @@ -1,9 +1,14 @@ import React, { ReactNode } from 'react'; import classNames from 'classnames'; +export interface IItem { + id: string; // 고유 식별자 + content: ReactNode; +} + interface IHeaderProps { - leftItems?: ReactNode[]; - rightItems?: ReactNode[]; + leftItems?: IItem[]; + rightItems?: IItem[]; title?: ReactNode; subtitle?: string; subtitleIcons?: React.ComponentType<{}>; @@ -23,7 +28,9 @@ export const HeaderLayout = (props: IHeaderProps) => {
{props.leftItems && props.leftItems.map(item => ( -
{item}
+
+ {item.content} +
))}
{props.userName}
@@ -33,7 +40,9 @@ export const HeaderLayout = (props: IHeaderProps) => {
{props.rightItems && props.rightItems.map(item => ( -
{item}
+
+ {item.content} +
))}
diff --git a/frontend/src/component/layout/constant/HeaderConst.ts b/frontend/src/component/layout/constant/HeaderConst.ts index 752fa5d2..cc8a3a8b 100644 --- a/frontend/src/component/layout/constant/HeaderConst.ts +++ b/frontend/src/component/layout/constant/HeaderConst.ts @@ -1,6 +1,7 @@ import { HeaderBackButton } from '@/component/header/HeaderBackButton'; import { HeaderDropdown } from '@/component/header/HeaderDropdown'; -import React, { ReactNode } from 'react'; +import { IItem } from '@/component/header/HeaderLayout'; +import React from 'react'; import { MdInfo } from 'react-icons/md'; export const HEADER_TITLE: Record = { @@ -15,17 +16,19 @@ export const HEADER_SUBTITLEICONS: Record = { '/add-channel/:user/draw': MdInfo, }; -export const HEADER_LEFTITEMS: Record = { - '/add-channel': [React.createElement(HeaderBackButton)], - '/add-channel/:user': [React.createElement(HeaderBackButton)], - '/add-channel/:user/draw': [React.createElement(HeaderBackButton)], - '/channel/:channelId/host': [React.createElement(HeaderBackButton)], - '/update-channel': [React.createElement(HeaderBackButton)], - '/register': [React.createElement(HeaderBackButton)], - '/channelInfo/:channelId': [React.createElement(HeaderBackButton)], - '/guest-add-channel/:channelId': [React.createElement(HeaderBackButton)], +export const HEADER_LEFTITEMS: Record = { + '/add-channel': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/add-channel/:user': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/add-channel/:user/draw': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/channel/:channelId/host': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/update-channel': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/register': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/channelInfo/:channelId': [{ id: 'item1', content: React.createElement(HeaderBackButton) }], + '/guest-add-channel/:channelId': [ + { id: 'item1', content: React.createElement(HeaderBackButton) }, + ], }; -export const HEADER_RIGHTITEMS: Record = { - '/channel/:channelId/host': [React.createElement(HeaderDropdown)], +export const HEADER_RIGHTITEMS: Record = { + '/channel/:channelId/host': [{ id: 'item1', content: React.createElement(HeaderDropdown) }], }; diff --git a/frontend/src/component/routebutton/RouteResultButton.tsx b/frontend/src/component/routebutton/RouteResultButton.tsx index 984725a9..49a4f06b 100644 --- a/frontend/src/component/routebutton/RouteResultButton.tsx +++ b/frontend/src/component/routebutton/RouteResultButton.tsx @@ -11,6 +11,7 @@ import { Page } from './enum'; interface IRouteResultButtonProps { user: IUser; + setUserName?: (index: number, newName: string) => void; deleteUser?: (index: number) => void; page?: Page; isGuest?: boolean; @@ -32,7 +33,7 @@ export const RouteResultButton = (props: IRouteResultButtonProps) => { .writeText(url) .then(() => { props.showAlert?.( - `${channelInfo.name} 경로의 링크가 복사되었습니다\n사용자에게 링크를 보내주세요!\n\n${url}`, + `${channelInfo.name} 경로의 링크가 복사되었습니다\n${props.user.name}에게 링크를 보내주세요!\n\n${url}`, ); }) .catch(() => { @@ -40,10 +41,24 @@ export const RouteResultButton = (props: IRouteResultButtonProps) => { }); }; + const handleChangeName = (event: React.ChangeEvent) => { + if (props.setUserName) { + props.setUserName(props.user.index, event.target.value); + } + }; + return (
-

{props.user.name}

+ { + handleChangeName(e); + }} + disabled={props.isGuest} + />
)} - {props.isGuest ? ( + {props.isGuest && props.setUserName ? (
) : props.user.index === 1 && props.page === Page.ADD ? (
diff --git a/frontend/src/component/routebutton/RouteSettingButton.tsx b/frontend/src/component/routebutton/RouteSettingButton.tsx index 2082899d..ae1cb526 100644 --- a/frontend/src/component/routebutton/RouteSettingButton.tsx +++ b/frontend/src/component/routebutton/RouteSettingButton.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; interface IRouteSettingButtonProps { user: IUser; + setUserName: (index: number, newName: string) => void; deleteUser?: (index: number) => void; } @@ -15,10 +16,21 @@ export const RouteSettingButton = (props: IRouteSettingButtonProps) => { navigate(`/add-channel/${user}/draw`); }; + const handleChangeName = (event: React.ChangeEvent) => { + props.setUserName(props.user.index, event.target.value); + }; + return (
-

{props.user.name}

+ { + handleChangeName(e); + }} + />
))}
diff --git a/frontend/src/component/setCurrentLocationButton/SetCurrentLocationButton.tsx b/frontend/src/component/setCurrentLocationButton/SetCurrentLocationButton.tsx new file mode 100644 index 00000000..cadd6f7f --- /dev/null +++ b/frontend/src/component/setCurrentLocationButton/SetCurrentLocationButton.tsx @@ -0,0 +1,31 @@ +import classNames from 'classnames'; +import { MdMyLocation } from 'react-icons/md'; + +interface ISetCurruntLocationButton { + map: naver.maps.Map | null; + lat: number | null; + lng: number | null; + isMain?: boolean; +} + +export const SetCurrentLocationButton = (props: ISetCurruntLocationButton) => { + const handleCurrentLocationButton = () => { + if (props.lat && props.lng) { + props.map?.setCenter(new window.naver.maps.LatLng(props.lat, props.lng)); + props.map?.setZoom(14); + } + }; + + return ( + + ); +}; diff --git a/frontend/src/component/zoomslider/ZoomSlider.css b/frontend/src/component/zoomslider/ZoomSlider.css new file mode 100644 index 00000000..f7098168 --- /dev/null +++ b/frontend/src/component/zoomslider/ZoomSlider.css @@ -0,0 +1,34 @@ +.rangeInput { + width: 90%; + border-radius: 8px; + outline: none; + transition: background 450ms ease-in; + -webkit-appearance: none; + accent-color: #333c4a; + height: 8px; +} + +.rangeInput::-webkit-slider-thumb { + -webkit-appearance: none; + height: 15px; + width: 15px; + background-color: #333c4a; + border-radius: 50%; + cursor: pointer; + transform: translateY(-25%); + position: relative; +} +.rangeInput::-moz-range-thumb { + height: 15px; + width: 15px; + background-color: #333c4a; + border-radius: 50%; + cursor: pointer; + transform: translateY(-25%); + position: relative; +} + +.rangeInput::-webkit-slider-runnable-track { + height: 8px; + border-radius: 8px; +} diff --git a/frontend/src/component/zoomslider/ZoomSlider.tsx b/frontend/src/component/zoomslider/ZoomSlider.tsx index 98071fdd..facf1805 100644 --- a/frontend/src/component/zoomslider/ZoomSlider.tsx +++ b/frontend/src/component/zoomslider/ZoomSlider.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; import { MdOutlineAdd, MdRemove } from 'react-icons/md'; +import './ZoomSlider.css'; interface IZoomSliderProps { /** Naver 지도 객체 */ @@ -28,29 +29,41 @@ const ZoomButton = ({ label, onClick }: IZoomButtonProps) => ( ); -const ZoomSliderInput = ({ zoomLevel, onSliderChange }: IZoomSliderInputProps) => ( -
- -
-
-); +const ZoomSliderInput = ({ zoomLevel, onSliderChange }: IZoomSliderInputProps) => { + const minZoom = 6; + const maxZoom = 22; + + const getBackgroundStyle = () => { + const gradientValue = ((zoomLevel - minZoom) / (maxZoom - minZoom)) * 100; + return `linear-gradient(to right, #333C4A 0%, #333C4A ${gradientValue}%, #ececec ${gradientValue}%, #ececec 100%)`; + }; + + return ( +
+ +
+
+ ); +}; export const ZoomSlider = ({ map, redrawCanvas }: IZoomSliderProps) => { const [zoomLevel, setZoomLevel] = useState(map?.getZoom() ?? 10); @@ -82,8 +95,25 @@ export const ZoomSlider = ({ map, redrawCanvas }: IZoomSliderProps) => { } }; + const handleTouchStart = (event: React.TouchEvent) => { + event.stopPropagation(); + }; + + const handleTouchMove = (event: React.TouchEvent) => { + event.stopPropagation(); + }; + + const handleTouchEnd = (event: React.TouchEvent) => { + event.stopPropagation(); + }; + return ( -
+
} onClick={() => handleZoomChange(1)} /> } onClick={() => handleZoomChange(-1)} /> diff --git a/frontend/src/hooks/getUserLocation.ts b/frontend/src/hooks/getUserLocation.ts index 2f114a84..a62b216e 100644 --- a/frontend/src/hooks/getUserLocation.ts +++ b/frontend/src/hooks/getUserLocation.ts @@ -7,7 +7,7 @@ interface IGetUserLocation { error: string | null; } -interface IDeviceOrientationEventWithPermission extends DeviceOrientationEvent { +export interface IDeviceOrientationEventWithPermission extends DeviceOrientationEvent { requestPermission?: () => Promise<'granted' | 'denied'>; } diff --git a/frontend/src/hooks/useCluster.ts b/frontend/src/hooks/useCluster.ts new file mode 100644 index 00000000..49e0c3e0 --- /dev/null +++ b/frontend/src/hooks/useCluster.ts @@ -0,0 +1,92 @@ +import { IMarkerStyle } from '@/lib/types/canvasInterface'; + +interface IPixel { + x: number; + y: number; +} + +interface IMarker { + lat: number; + lng: number; +} + +export interface ICluster { + markers: IMarker[]; + center: IMarker; + color: IMarkerStyle; +} + +export const useCluster = () => { + const calculateDistance = (pointA: IPixel, pointB: IPixel) => { + // 유클리드 거리 계산 (픽셀 단위) + const dx = pointA.x - pointB.x; + const dy = pointA.y - pointB.y; + return Math.sqrt(dx * dx + dy * dy); + }; + + const createClusters = (markers: IMarker[], color: IMarkerStyle, map: naver.maps.Map) => { + const projection = map.getProjection(); + const latLngToCanvasPoint = (latLng: naver.maps.LatLng): IPixel | null => { + if (!map || !projection) return null; + + const lat = latLng.lat(); + const lng = latLng.lng(); + + const coord = projection.fromCoordToOffset(new naver.maps.LatLng(lat, lng)); + const mapSize = map.getSize(); + const mapCenter = map.getCenter(); + const centerPoint = projection.fromCoordToOffset(mapCenter); + + return { + x: coord.x - (centerPoint.x - mapSize.width / 2), + y: coord.y - (centerPoint.y - mapSize.height / 2), + }; + }; + + const clusters: ICluster[] = []; + const visited = new Set(); + const clusterRadius = 50; + + if (!projection) { + console.error('Projection을 가져올 수 없습니다.'); + return clusters; + } + + markers.forEach((marker, index) => { + if (visited.has(index)) return; + + // 새로운 클러스터 생성 + const cluster = { markers: [marker], center: marker, color }; + visited.add(index); + + // 현재 마커와 가까운 마커 찾기 + for (let i = index + 1; i < markers.length; i++) { + if (!visited.has(i)) { + const markerPixel = latLngToCanvasPoint(new naver.maps.LatLng(marker.lat, marker.lng)); + const otherMarkerPixel = latLngToCanvasPoint( + new naver.maps.LatLng(markers[i].lat, markers[i].lng), + ); + if (markerPixel !== null && otherMarkerPixel !== null) { + const distance = calculateDistance(markerPixel, otherMarkerPixel); + if (distance < clusterRadius) { + cluster.markers.push(markers[i]); + visited.add(i); + } + } + } + } + // 클러스터의 중심 계산: 마커들의 위도와 경도의 평균값 + const centerLat = cluster.markers.reduce((sum, m) => sum + m.lat, 0) / cluster.markers.length; + const centerLng = cluster.markers.reduce((sum, m) => sum + m.lng, 0) / cluster.markers.length; + + cluster.center = { lat: centerLat, lng: centerLng }; + if (cluster.markers.length > 1) { + clusters.push(cluster); + } + }); + + return clusters; + }; + + return { createClusters }; +}; diff --git a/frontend/src/hooks/useRedraw.ts b/frontend/src/hooks/useRedraw.ts index bcd6530d..1a851e27 100644 --- a/frontend/src/hooks/useRedraw.ts +++ b/frontend/src/hooks/useRedraw.ts @@ -14,6 +14,7 @@ import character1 from '@/assets/character1.png'; import character2 from '@/assets/character2.png'; import { IMarkerStyle } from '@/lib/types/canvasInterface.ts'; import footprint from '@/assets/footprint.svg'; +import { ICluster } from './useCluster'; interface ILatLng { lat: number; @@ -52,12 +53,14 @@ interface IUseRedrawCanvasProps { lat?: number; lng?: number; alpha?: number | null; + clusters?: ICluster[] | null; } enum MARKER_TYPE { START_MARKER = 'START_MARKER', END_MARKER = 'END_MARKER', CHARACTER = 'CHARACTER', + CLUSTER = 'CLUSTER', } export const useRedrawCanvas = ({ @@ -72,6 +75,7 @@ export const useRedrawCanvas = ({ lat, lng, alpha = 0, + clusters = [], }: IUseRedrawCanvasProps) => { const startImageRef = useRef(null); const endImageRef = useRef(null); @@ -124,6 +128,28 @@ export const useRedrawCanvas = ({ return tempCanvas; }; + const drawCluster = ( + ctx: CanvasRenderingContext2D, + cluster: ICluster, + image: HTMLImageElement | null, + zoom: number, + color: string, + ) => { + if (!cluster || !cluster.center || !cluster.markers.length) return; + + // 클러스터 중심을 캔버스 좌표로 변환 + const clusterCenter = latLngToCanvasPoint(cluster.center); + + if (clusterCenter && image) { + const markerSize = zoom < 18 ? Math.min(zoom * 5, 50) : (zoom - 15) * (zoom - 16) * 10; + ctx.save(); + ctx.translate(clusterCenter.x, clusterCenter.y - zoom); + ctx.rotate(0); + const filteredImage = colorizeImage(image, color, markerSize, markerSize); + ctx.drawImage(filteredImage, -markerSize / 2, -markerSize / 2, markerSize, markerSize); + ctx.restore(); + } + }; const drawMarker = ( ctx: CanvasRenderingContext2D, @@ -143,7 +169,7 @@ export const useRedrawCanvas = ({ } ctx.save(); - ctx.translate(point.x, point.y - zoom); + ctx.translate(point.x, point.y + zoom); ctx.rotate(rotate); let filteredImage; if (markerType === MARKER_TYPE.CHARACTER) { @@ -218,9 +244,25 @@ export const useRedrawCanvas = ({ const footprintImage = footprintRef.current; const markerSize = Math.min(map.getZoom() * 2, 20); - + const offsetDistance = markerSize * 0.3; const offscreenCanvas = colorizeImage(footprintImage, color, markerSize, markerSize); + const path = new Path2D(); + + ctx.beginPath(); + ctx.setLineDash([10, 5]); + + if (points.length === 1) { + const point = latLngToCanvasPoint(points[0]); + if (point) { + ctx.save(); + ctx.translate(point.x, point.y); + ctx.drawImage(offscreenCanvas, -markerSize / 2, -markerSize / 2); // 발자국 이미지 그리기 + ctx.restore(); + } + return; + } + for (let i = 0; i < points.length - 1; i++) { const start = latLngToCanvasPoint(points[i]); const end = latLngToCanvasPoint(points[i + 1]); @@ -230,9 +272,11 @@ export const useRedrawCanvas = ({ continue; } - const angle = Math.atan2(end.y - start.y, end.x - start.x); + path.moveTo(start.x, start.y); + path.lineTo(end.x, end.y); - const distance = 30; + const angle = Math.atan2(end.y - start.y, end.x - start.x); + const distance = 25; const totalDistance = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2); const steps = Math.floor(totalDistance / distance); @@ -241,14 +285,24 @@ export const useRedrawCanvas = ({ const x = start.x + progress * (end.x - start.x); const y = start.y + progress * (end.y - start.y); + const isLeftFoot = j % 2 === 0; + const offsetX = + Math.cos(angle + (isLeftFoot ? Math.PI / 2 : -Math.PI / 2)) * offsetDistance; + const offsetY = + Math.sin(angle + (isLeftFoot ? Math.PI / 2 : -Math.PI / 2)) * offsetDistance; + ctx.save(); - ctx.translate(x, y); + ctx.translate(x + offsetX, y + offsetY); ctx.rotate(angle + Math.PI / 2); - ctx.drawImage(offscreenCanvas, -markerSize / 2, -markerSize / 2); + ctx.drawImage(offscreenCanvas, -markerSize / 2, -markerSize / 2); // 발자국 이미지 그리기 ctx.restore(); } - ctx.stroke(); } + + ctx.strokeStyle = hexToRgba(color, 0.1); + ctx.lineWidth = 10; + ctx.stroke(path); + ctx.setLineDash([]); }; const redrawCanvas = () => { @@ -264,75 +318,98 @@ export const useRedrawCanvas = ({ ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - // 호스트가 게스트 경로 그릴때 쓰이는 디자인 - const zoom = map.getZoom(); - if (startMarker) { - const startPoint = latLngToCanvasPoint(startMarker); - drawMarker( - ctx, - startPoint, - startImageRef.current, - zoom, - 0, - START_MARKER_COLOR, - MARKER_TYPE.START_MARKER, - ); - } - - if (endMarker) { - const endPoint = latLngToCanvasPoint(endMarker); - drawMarker( - ctx, - endPoint, - endImageRef.current, - zoom, - 0, - END_MARKER_COLOR, - MARKER_TYPE.END_MARKER, + const clusteredMarkerSet = new Set(); + clusters?.forEach(cluster => { + cluster.markers.forEach((marker: any) => + clusteredMarkerSet.add(`${marker.lat.toFixed(6)}_${marker.lng.toFixed(6)}`), ); - } - - if (pathPoints) { - drawPath(ctx, pathPoints, PATH_COLOR); - } + }); + const zoom = map.getZoom(); if (guests) { guests.forEach(({ startPoint, endPoint, paths, markerStyle }) => { - const startLocation = latLngToCanvasPoint(startPoint); + const startLocationKey = `${startPoint.lat.toFixed(6)}_${startPoint.lng.toFixed(6)}`; + const endLocationKey = `${endPoint.lat.toFixed(6)}_${endPoint.lng.toFixed(6)}`; + if (!clusteredMarkerSet.has(startLocationKey)) { + const startLocation = latLngToCanvasPoint(startPoint); + drawMarker( + ctx, + startLocation, + startImageRef.current, + zoom, + 0, + markerStyle.color, + MARKER_TYPE.START_MARKER, + ); + } + + if (!clusteredMarkerSet.has(endLocationKey)) { + const endLocation = latLngToCanvasPoint(endPoint); + drawMarker( + ctx, + endLocation, + endImageRef.current, + zoom, + 0, + markerStyle.color, + MARKER_TYPE.END_MARKER, + ); + } + + // 경로는 두 포인트 중 하나라도 클러스터에 포함되지 않으면 그리기 + if (!clusteredMarkerSet.has(startLocationKey) || !clusteredMarkerSet.has(endLocationKey)) { + drawPath(ctx, paths, markerStyle.color); + } + }); + } + + if (startMarker) { + const startPoint = latLngToCanvasPoint(startMarker); + const markerKey = `${startMarker.lat.toFixed(6)}_${startMarker.lng.toFixed(6)}`; + if (!clusteredMarkerSet.has(markerKey)) { drawMarker( ctx, - startLocation, + startPoint, startImageRef.current, zoom, 0, - markerStyle.color, + START_MARKER_COLOR, MARKER_TYPE.START_MARKER, ); + } + } - const endLocation = latLngToCanvasPoint(endPoint); + if (endMarker) { + const endPoint = latLngToCanvasPoint(endMarker); + const markerKey = `${endMarker.lat.toFixed(6)}_${endMarker.lng.toFixed(6)}`; + if (!clusteredMarkerSet.has(markerKey)) { drawMarker( ctx, - endLocation, + endPoint, endImageRef.current, zoom, 0, - markerStyle.color, + END_MARKER_COLOR, MARKER_TYPE.END_MARKER, ); + } + } - drawPath(ctx, paths, markerStyle.color); - }); + if (pathPoints) { + drawPath(ctx, pathPoints, PATH_COLOR); } if (lat && lng) { const currentLocation = latLngToCanvasPoint({ lat, lng }); if (alpha) { + const normalizedAlpha = (alpha + 360) % 360; + const correctedAlpha = ((90 - normalizedAlpha + 360) % 360) * (Math.PI / 180); drawMarker( ctx, currentLocation, character1Ref.current, zoom, - (alpha * Math.PI) / 180, + correctedAlpha, guests![0]?.markerStyle.color, MARKER_TYPE.CHARACTER, ); @@ -355,6 +432,8 @@ export const useRedrawCanvas = ({ lat: location.lat ? location.lat : 0, lng: location.lng ? location.lng : 0, }); + const normalizedAlpha = (location.alpha + 360) % 360; + const correctedAlpha = ((90 - normalizedAlpha + 360) % 360) * (Math.PI / 180); drawNeonCircleAndDirection(ctx, locationPoint, zoom, color); drawMarker( @@ -362,12 +441,18 @@ export const useRedrawCanvas = ({ locationPoint, character2Ref.current, zoom, - (location.alpha * Math.PI) / 180, + correctedAlpha, color, MARKER_TYPE.CHARACTER, ); }); } + + if (clusters) { + clusters.forEach(cluster => { + drawCluster(ctx, cluster, startImageRef.current, zoom, cluster.color.color); + }); + } }; return { redrawCanvas }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 03968b36..6967f2d5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,5 +1,3 @@ -/* src/index.css */ - @tailwind base; @tailwind components; @tailwind utilities; @@ -12,6 +10,7 @@ * { overscroll-behavior: none; overscroll-behavior-y: none; + touch-action: none; } *::before, *::after { @@ -20,8 +19,13 @@ box-sizing: border-box; /* padding과 border를 box 크기에 포함 */ } -html, -body, +html { + overflow: hidden; + overscroll-behavior: none; + overscroll-behavior-y: none; + touch-action: manipulation; +} +body div, span, applet, diff --git a/frontend/src/lib/constants/mapConstants.ts b/frontend/src/lib/constants/mapConstants.ts index 9ea59a6e..d1eb30f0 100644 --- a/frontend/src/lib/constants/mapConstants.ts +++ b/frontend/src/lib/constants/mapConstants.ts @@ -6,9 +6,11 @@ export const KOREA_BOUNDS = { // 초기 중심점 (대한민국 중앙 근처) export const DEFAULT_CENTER = { - lat: (KOREA_BOUNDS.sw.lat + KOREA_BOUNDS.ne.lat) / 2, - lng: (KOREA_BOUNDS.sw.lng + KOREA_BOUNDS.ne.lng) / 2, + lat: 37.35921406312911, + lng: 127.10430493337978, }; export const MIN_ZOOM = 7; // 대한민국 전체가 보이는 최소 줌 레벨 export const MAX_ZOOM = 19; // 네이버 지도 최대 줌 레벨 + +export const DEFAULT_ZOOM = 18; diff --git a/frontend/src/lib/types/canvasInterface.ts b/frontend/src/lib/types/canvasInterface.ts index f71be6d8..3c07d278 100644 --- a/frontend/src/lib/types/canvasInterface.ts +++ b/frontend/src/lib/types/canvasInterface.ts @@ -53,4 +53,5 @@ export interface IMapCanvasViewProps { guests?: IGuestDataInMapProps[] | null; width: string; height: string; + isMain: boolean; } diff --git a/frontend/src/pages/AddChannel.tsx b/frontend/src/pages/AddChannel.tsx index 8c6d291f..8c847dba 100644 --- a/frontend/src/pages/AddChannel.tsx +++ b/frontend/src/pages/AddChannel.tsx @@ -47,6 +47,7 @@ export const AddChannel = () => { setFooterOnClick, resetFooterContext, } = useContext(FooterContext); + const navigate = useNavigate(); const goToMainPage = () => { navigate('/'); @@ -94,6 +95,9 @@ export const AddChannel = () => { user.marker_style.color !== '' ); }; + const isChannelNameComplete = (): boolean => { + return channelName.trim() !== ''; + }; /** * 사용자 추가 함수 @@ -119,11 +123,18 @@ export const AddChannel = () => { .map((user, i) => ({ ...user, index: i + 1, - name: `사용자${i + 1}`, + name: user.name || `사용자${i + 1}`, })); setUsers(updatedUsers); }; + const setUserName = (index: number, newName: string) => { + const updatedUsers = users.map(user => + user.index === index ? { ...user, name: newName } : user, + ); + setUsers(updatedUsers); + }; + const handleChangeChannelName = (event: React.ChangeEvent) => { setChannelName(event.target.value); }; @@ -139,21 +150,25 @@ export const AddChannel = () => { addUser(); // users가 비어있다면 기본 사용자 추가 } const allUsersComplete = users.every(isUserDataComplete); - // 모든 사용자가 완전한 데이터라면 Footer를 활성화 - if (allUsersComplete) { + if (allUsersComplete && isChannelNameComplete()) { setFooterActive(buttonActiveType.ACTIVE); } else { setFooterActive(buttonActiveType.PASSIVE); } - }, [users, setFooterActive]); // users가 변경될 때마다 실행 + }, [users, setFooterActive, channelName]); // users가 변경될 때마다 실행 const createChannelAPI = async () => { try { const userId = loadLocalData(AppConfig.KEYS.LOGIN_USER); + + if (!userId) { + console.error('유효하지 않은 사용자 ID입니다.'); + return; + } const channelData: createChannelReqEntity = { name: channelName, - host_id: userId ?? undefined, // 추후 검증 로직 추가 예정 + host_id: userId, // 추후 검증 로직 추가 예정 guests: users.map(user => ({ name: user.name, start_location: { @@ -173,8 +188,9 @@ export const AddChannel = () => { marker_style: user.marker_style, })), }; - const response = await createChannel(channelData); - console.log('채널 생성 성공:', response); + + const res = await createChannel(channelData); + console.log(res.resultMsg); } catch (error) { console.error('채널 생성 실패:', error); } @@ -200,9 +216,14 @@ export const AddChannel = () => { {users.map(user => (
{isUserDataComplete(user) ? ( - + ) : ( - + )}
))} diff --git a/frontend/src/pages/AddGuestPage.tsx b/frontend/src/pages/AddGuestPage.tsx index cdb0e33a..f2de144d 100644 --- a/frontend/src/pages/AddGuestPage.tsx +++ b/frontend/src/pages/AddGuestPage.tsx @@ -118,7 +118,7 @@ export const AddGuestPage = () => { .map((user, i) => ({ ...user, index: guests.length + i + 1, - name: `사용자${guests.length + i + 1}`, + name: user.name || `사용자${guests.length + i + 1}`, })); setUsers(updatedUsers); }; @@ -157,7 +157,7 @@ export const AddGuestPage = () => { }; useEffect(() => { - setFooterTitle('제작 완료'); + setFooterTitle('수정 완료'); setFooterTransparency(false); setFooterActive(buttonActiveType.PASSIVE); if (channelInfo?.guests) { @@ -182,6 +182,13 @@ export const AddGuestPage = () => { } }, [footerOption.active]); + const setUserName = (index: number, newName: string) => { + const updatedUsers = users.map(user => + user.index === index ? { ...user, name: newName } : user, + ); + setUsers(updatedUsers); + }; + return (
@@ -194,16 +201,21 @@ export const AddGuestPage = () => {
{guests.map(guest => (
- +
))} {users && users.map(user => (
{isUserDataComplete(user) ? ( - + ) : ( - + )}
))} diff --git a/frontend/src/pages/ChannelInfoPage.tsx b/frontend/src/pages/ChannelInfoPage.tsx index 285e4ae1..d3f269ac 100644 --- a/frontend/src/pages/ChannelInfoPage.tsx +++ b/frontend/src/pages/ChannelInfoPage.tsx @@ -92,7 +92,7 @@ export const ChannelInfoPage = () => {
{users.map(user => (
- +
))}
diff --git a/frontend/src/pages/DrawRoute.tsx b/frontend/src/pages/DrawRoute.tsx index ab16dd54..81361ee4 100644 --- a/frontend/src/pages/DrawRoute.tsx +++ b/frontend/src/pages/DrawRoute.tsx @@ -10,8 +10,13 @@ import { getAddressFromCoordinates } from '@/utils/map/getAddress'; export const DrawRoute = () => { const { users, setUsers } = useContext(UserContext); - const { setFooterTitle, setFooterActive, setFooterOnClick, resetFooterContext } = - useContext(FooterContext); + const { + setFooterTitle, + setFooterActive, + setFooterOnClick, + setFooterTransparency, + resetFooterContext, + } = useContext(FooterContext); const { currentUser, setCurrentUser } = useContext(CurrentUserContext); const params = useParams>(); const navigate = useNavigate(); @@ -51,6 +56,7 @@ export const DrawRoute = () => { useEffect(() => { setFooterTitle('사용자 경로 추가 완료'); + setFooterTransparency(false); setFooterActive(buttonActiveType.PASSIVE); const user = getUser(); if (user) { @@ -130,7 +136,7 @@ export const DrawRoute = () => { return (
-
+
diff --git a/frontend/src/pages/GuestView.tsx b/frontend/src/pages/GuestView.tsx index de77ebc6..eea0b454 100644 --- a/frontend/src/pages/GuestView.tsx +++ b/frontend/src/pages/GuestView.tsx @@ -7,7 +7,7 @@ import { IPoint } from '@/lib/types/canvasInterface.ts'; import { guestEntity } from '@/api/dto/channel.dto.ts'; import { GuestMarker } from '@/component/IconGuide/GuestMarker.tsx'; import { LoadingSpinner } from '@/component/common/loadingSpinner/LoadingSpinner.tsx'; -import { getUserLocation } from '@/hooks/getUserLocation.ts'; +import { getUserLocation, IDeviceOrientationEventWithPermission } from '@/hooks/getUserLocation.ts'; import { loadLocalData, saveLocalData } from '@/utils/common/manageLocalData.ts'; import { AppConfig } from '@/lib/constants/commonConstants.ts'; import { v4 as uuidv4 } from 'uuid'; @@ -31,6 +31,33 @@ export const GuestView = () => { const wsRef = useRef(null); + useEffect(() => { + const requestOrientationPermission = async () => { + const DeviceOrientationEventTyped = + DeviceOrientationEvent as unknown as IDeviceOrientationEventWithPermission; + + if ( + typeof DeviceOrientationEventTyped !== 'undefined' && + typeof DeviceOrientationEventTyped.requestPermission === 'function' + ) { + try { + const permission = await DeviceOrientationEventTyped.requestPermission(); + if (permission === 'granted') { + console.log('Device Orientation permission granted.'); + } else { + console.error('Device Orientation permission denied.'); + } + } catch { + console.error('Failed to request Device Orientation permission:'); + } + } else { + console.warn('DeviceOrientationEvent.requestPermission is not supported on this browser.'); + } + }; + + requestOrientationPermission(); + }, []); + useEffect(() => { // 소켓 연결 초기화 const token = loadLocalData(AppConfig.KEYS.BROWSER_TOKEN) || uuidv4(); @@ -127,6 +154,7 @@ export const GuestView = () => { width="100%" height="100%" guests={[guestInfo]} + isMain={false} /> ) : ( diff --git a/frontend/src/pages/HostView.tsx b/frontend/src/pages/HostView.tsx index 0423c540..c209f816 100644 --- a/frontend/src/pages/HostView.tsx +++ b/frontend/src/pages/HostView.tsx @@ -48,6 +48,7 @@ export const HostView = () => { const token = uuidv4(); saveLocalData(AppConfig.KEYS.BROWSER_TOKEN, token); } + const token = loadLocalData(AppConfig.KEYS.BROWSER_TOKEN); const url = `${AppConfig.SOCKET_SERVER}/?token=${token}&channelId=${location.pathname.split('/')[2]}&role=host`; @@ -230,6 +231,7 @@ export const HostView = () => { guests={mapProps} otherLocations={filteredOtherLocations} ref={mapRef} + isMain={false} /> ) : ( diff --git a/frontend/src/pages/Main.tsx b/frontend/src/pages/Main.tsx index d5764146..d80fe3ba 100644 --- a/frontend/src/pages/Main.tsx +++ b/frontend/src/pages/Main.tsx @@ -1,11 +1,11 @@ -import { Fragment, useContext, useEffect, useState } from 'react'; -import { MdLogout } from 'react-icons/md'; +import { Fragment, useContext, useEffect, useRef, useState, ReactNode } from 'react'; +import { MdInfo, MdLogout } from 'react-icons/md'; import { FooterContext } from '@/component/layout/footer/LayoutFooterProvider'; import { useNavigate } from 'react-router-dom'; import { buttonActiveType } from '@/component/layout/enumTypes'; import { loadLocalData, saveLocalData, removeLocalData } from '@/utils/common/manageLocalData.ts'; import { AuthModal } from '@/component/authmodal/AuthModal'; -import { getUserChannels } from '@/api/channel.api.ts'; +import { deleteChannel, getUserChannels } from '@/api/channel.api.ts'; import { BottomSheet } from '@/component/bottomsheet/BottomSheet.tsx'; import { Content } from '@/component/content/Content.tsx'; import { AppConfig } from '@/lib/constants/commonConstants.ts'; @@ -15,6 +15,7 @@ import { MapCanvasForView } from '@/component/canvasWithMap/canvasWithMapForView import { LoadingSpinner } from '@/component/common/loadingSpinner/LoadingSpinner.tsx'; import { UserContext } from '@/context/UserContext'; import { ToggleProvider } from '@/context/DropdownContext.tsx'; +import { Confirm } from '@/component/confirm/Confirm.tsx'; export const Main = () => { const { @@ -24,6 +25,7 @@ export const Main = () => { setFooterActive, resetFooterContext, } = useContext(FooterContext); + const mapRef = useRef(null); const { lat, lng, alpha, error } = getUserLocation(); const [otherLocations, setOtherLocations] = useState([]); const MIN_HEIGHT = 0.15; @@ -31,10 +33,58 @@ export const Main = () => { const [isLoggedIn, setIsLoggedIn] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); const [channels, setChannels] = useState([]); + const [modalState, setModalState] = useState<'none' | 'confirm' | 'alert'>('none'); + const [modal, setModal] = useState(false); + + const deleteTargetRef = useRef(''); const { resetUsers } = useContext(UserContext); + const handleDeleteChannel = (channelId: string) => { + setModalState('confirm'); + deleteTargetRef.current = channelId; + // setIsDeleted(prev => !prev); + }; + + const handleDeleteModalCancel = () => { + setModalState('none'); + }; + + const handleDeleteModalConfirm = async () => { + try { + await deleteChannel(deleteTargetRef.current); + setModalState('alert'); + } catch (err) { + console.error('Failed to delete channel info:', err); + } + }; + useEffect(() => { + if (modalState === 'confirm') { + setModal( + , + ); + return; + } + if (modalState === 'alert') { + setModal( + { + setModalState('none'); + }} + onCancel={() => {}} + type="alert" + />, + ); + return; + } + const token = loadLocalData(AppConfig.KEYS.LOGIN_TOKEN); setIsLoggedIn(!!token); @@ -52,7 +102,7 @@ export const Main = () => { }); } } - }, []); + }, [modalState]); const navigate = useNavigate(); @@ -80,8 +130,16 @@ export const Main = () => { ? prev.map(loc => (loc.token === data.token ? data : loc)) : [...prev, data], ); + } else if (data.type === 'channel') { + setChannels(prevChannels => { + if (prevChannels.some(channel => channel.id === data.channel.id)) { + return prevChannels; + } + return [...prevChannels, data.channel]; + }); } }; + return () => ws.close(); } return undefined; @@ -114,12 +172,25 @@ export const Main = () => { setIsLoggedIn(!isLoggedIn); }; + const handleOnBoardingButton = () => { + saveLocalData(AppConfig.KEYS.FIRST_VISIT, 'true'); + window.location.reload(); + }; + const isUserLoggedIn = loadLocalData(AppConfig.KEYS.LOGIN_TOKEN) !== null; return (
-
+
+ {isUserLoggedIn && (
{isUserLoggedIn ? ( - + {channels.map(item => ( { link={`/channel/${item.id}/host`} person={item.guest_count} time={item.generated_at} + onDelete={handleDeleteChannel} />
))} -
+
) : ( - +

로그인을 진행하여

@@ -183,6 +271,8 @@ export const Main = () => { )} + {modalState !== 'none' && modal} + {/* 로그인 모달 */}