diff --git a/src/apis/museum/artwork.js b/src/apis/museum/artwork.js new file mode 100644 index 0000000..ca42cee --- /dev/null +++ b/src/apis/museum/artwork.js @@ -0,0 +1,320 @@ +import { APIService } from '../axios.js'; +import { useState, useEffect, useCallback } from 'react'; + +/** + * 내 작품 리스트 조회 API + * @param {Object} params - 쿼리 파라미터 + * @param {boolean} params.applicated - 등록 신청 여부 + * @param {number} params.pageNum - 페이지 번호 (기본값: 1) + * @param {number} params.pageSize - 페이지 크기 (기본값: 10) + * @returns {Promise} API 응답 데이터 + */ +export const getMyPieces = async (params = {}) => { + try { + const { applicated = true, pageNum = 1, pageSize = 3 } = params; + + const response = await APIService.private.get('/api/pieces/my-page', { + params: { + applicated, + pageNum, + pageSize + } + }); + + return response; + } catch (error) { + console.error('내 작품 조회 실패:', error); + throw error; + } +}; + +/** + * 내 작품 상세 정보 조회 API + * @param {string} pieceId - 작품 ID + * @returns {Promise} API 응답 데이터 + */ +export const getMyPieceDetail = async (pieceId) => { + try { + const response = await APIService.private.get(`/api/pieces/${pieceId}`); + return response; + } catch (error) { + console.error('내 작품 상세 조회 실패:', error); + throw error; + } +}; + +/** + * 작품 등록 API + * @param {Object} formData - 작품 등록 데이터 + * @param {string} formData.title - 작품명 + * @param {string} formData.description - 작품 소개 + * @param {File} formData.mainImage - 메인 이미지 파일 + * @param {File[]} formData.detailImages - 디테일 이미지 파일들 + * @param {string} saveStatus - 저장 상태 (DRAFT 또는 APPLICATION) + * @returns {Promise} API 응답 데이터 + */ +export const createPiece = async (formData, saveStatus = 'APPLICATION') => { + try { + const data = new FormData(); + + // 작품 정보를 JSON으로 변환하여 data 필드에 추가 + const pieceData = { + title: formData.title, + description: formData.description, + isPurchasable: true // 사용자 요구사항: 항상 true로 설정 + }; + + data.append('data', JSON.stringify(pieceData)); + + // 메인 이미지 추가 + if (formData.mainImage) { + data.append('mainImage', formData.mainImage); + } + + // 디테일 이미지들 추가 + if (formData.detailImages && formData.detailImages.length > 0) { + formData.detailImages.forEach((image, index) => { + if (image) { + data.append('detailImages', image); + } + }); + } + + const response = await APIService.private.post('/api/pieces', data, { + params: { saveStatus }, + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + + return response; + } catch (error) { + console.error('작품 등록 실패:', error); + throw error; + } +}; + +/** + * 작품 임시저장 API + * @param {Object} formData - 작품 임시저장 데이터 + * @returns {Promise} API 응답 데이터 + */ +export const saveDraftPiece = async (formData) => { + try { + const response = await createPiece(formData, 'DRAFT'); + return response; + } catch (error) { + console.error('작품 임시저장 실패:', error); + throw error; + } +}; + +/** + * 여러 작품 삭제 API + * @param {number[]} pieceIds - 삭제할 작품 ID 배열 + * @returns {Promise} API 응답 데이터 + */ +export const deletePieces = async (pieceIds) => { + try { + const response = await APIService.private.delete('/api/pieces', { + params: { + pieceIds: pieceIds.join(',') + } + }); + return response; + } catch (error) { + console.error('작품들 삭제 실패:', error); + throw error; + } +}; + +/** + * 임시저장된 작품 개수 조회 API + * @returns {Promise} API 응답 데이터 + */ +export const getPieceDraftCount = async () => { + try { + const response = await APIService.private.get('/api/pieces/draft-count'); + return response; + } catch (error) { + console.error('임시저장 작품 개수 조회 실패:', error); + throw error; + } +}; + + + +/** + * 무한스크롤을 위한 작품 목록 관리 훅 + * @param {Object} params - 쿼리 파라미터 + * @param {boolean} params.applicated - 등록 신청 여부 (기본값: true) + * @param {number} params.pageSize - 페이지 크기 (기본값: 3) + * @returns {object} 작품 데이터와 상태 관리 함수들 + */ +export const useInfinitePieces = (params = {}) => { + const { applicated = true, pageSize = 3 } = params; + + const [pieces, setPieces] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); // 1부터 시작 + const [totalElements, setTotalElements] = useState(0); + + // 작품 목록 초기화 + const resetPieces = useCallback(() => { + setPieces([]); + setCurrentPage(1); // 1부터 시작 + setHasMore(true); + setError(null); + setTotalElements(0); + }, []); + + // applicated 변경 시 초기화 + useEffect(() => { + resetPieces(); + }, [applicated, resetPieces]); + + // 첫 페이지 자동 로드 + useEffect(() => { + // applicated가 변경되거나 pieces가 비어있을 때만 실행 + if (pieces.length === 0 && !loading) { + const loadFirstPage = async () => { + if (loading || !hasMore) return; + + setLoading(true); + setError(null); + + try { + // 첫 페이지(1)로 API 호출 + const response = await getMyPieces({ + applicated, + pageNum: 1, + pageSize + }); + + // API 응답 구조: { content: [], last: boolean, totalElements: number } + const apiResponse = response.data || response; + const newPieces = apiResponse.content || []; + const isLastPage = apiResponse.last; // last가 true면 마지막 페이지 + const totalElements = apiResponse.totalElements || 0; + + setPieces(newPieces); + setHasMore(!isLastPage); // last가 true면 더 이상 로드하지 않음 + setTotalElements(totalElements); + setCurrentPage(2); // 다음 페이지는 2 + } catch (err) { + setError(err); + console.error('작품 로드 실패:', err); + } finally { + setLoading(false); + } + }; + + loadFirstPage(); + } + }, [applicated, pieces.length, loading, hasMore, pageSize]); + + // 다음 페이지 작품 로드 + const loadMorePieces = useCallback(async () => { + if (loading || !hasMore) return; + + setLoading(true); + setError(null); + + try { + // 현재 페이지 번호로 API 호출 + const response = await getMyPieces({ + applicated, + pageNum: currentPage, + pageSize + }); + + // API 응답 구조: { content: [], last: boolean, totalElements: number } + const apiResponse = response.data || response; + const newPieces = apiResponse.content || []; + const isLastPage = apiResponse.last; // last가 true면 마지막 페이지 + const totalElements = apiResponse.totalElements || 0; + + setPieces(prev => [...prev, ...newPieces]); + setHasMore(!isLastPage); // last가 true면 더 이상 로드하지 않음 + setTotalElements(totalElements); + + // 다음 페이지 번호로 업데이트 (현재 페이지 로드 완료 후) + setCurrentPage(prev => prev + 1); + } catch (err) { + setError(err); + console.error('작품 로드 실패:', err); + } finally { + setLoading(false); + } + }, [applicated, pageSize, currentPage, loading, hasMore]); + + return { + pieces, + loading, + hasMore, + error, + loadMorePieces, + resetPieces, + totalElements, + currentPage + }; +}; + +/** + * 작품 이미지 URL을 여러 가능한 필드에서 찾는 유틸리티 함수 + * @param {Object} artwork - 작품 객체 + * @returns {string|null} 이미지 URL 또는 null + */ +export const getImageUrl = (artwork) => { + if (!artwork) return null; + + // API에서 받아오는 imageUrl 필드를 우선적으로 확인 + const imageUrl = artwork.imageUrl || + artwork.mainImageUrl || + artwork.thumbnailUrl || + artwork.image || + artwork.mainImage; + + return imageUrl; +}; + +/** + * 작품 상태에 따른 스타일 클래스명을 반환하는 유틸리티 함수 + * @param {string} status - 작품 상태 + * @returns {string} CSS 클래스명 + */ +export const getStatusStyle = (status) => { + switch (status) { + case '전시 중': + return 'statusExhibiting'; + case '미승인': + return 'statusRejected'; + case '승인 대기': + return 'statusPending'; + default: + return 'statusDefault'; + } +}; + +/** + * 작품 제목을 안전하게 표시하는 유틸리티 함수 + * @param {string} title - 작품 제목 + * @param {string} fallback - 기본값 + * @returns {string} 표시할 제목 + */ +export const getArtworkTitle = (title, fallback = '제목 없음') => { + return title && title.trim() ? title.trim() : fallback; +}; + +/** + * 작품 설명을 안전하게 표시하는 유틸리티 함수 + * @param {string} description - 작품 설명 + * @param {string} fallback - 기본값 + * @returns {string} 표시할 설명 + */ +export const getArtworkDescription = (description, fallback = '설명 없음') => { + return description && description.trim() ? description.trim() : fallback; +}; + diff --git a/src/apis/exhibition/exhibition.js b/src/apis/museum/museum-exhibition.js similarity index 83% rename from src/apis/exhibition/exhibition.js rename to src/apis/museum/museum-exhibition.js index 5ca331a..0a77c7a 100644 --- a/src/apis/exhibition/exhibition.js +++ b/src/apis/museum/museum-exhibition.js @@ -1,6 +1,36 @@ import { useState, useEffect, useCallback } from 'react'; import APIService from '../axios'; +/** + * 전시 등록 API (multipart/form-data) + * @param {Object} exhibitionData - DTO 그대로 JSON으로 보낼 데이터 (Swagger의 request object와 동일) + * @param {File=} imageFile - 썸네일 파일(선택). 없으면 생략 가능 + * @returns {Promise} + */ +export const createExhibition = async (exhibitionData, imageFile) => { + try { + const formData = new FormData(); + + // ✅ 서버가 기대하는 "request" 파트에 JSON 통째로 담기 + formData.append( + "request", + new Blob([JSON.stringify(exhibitionData)], { type: "application/json" }) + ); + + // ✅ 이미지 파일 파트명은 Swagger의 "image"와 동일해야 함 + if (imageFile) { + formData.append("image", imageFile); + } + + // axios는 boundary 자동 설정 + const res = await APIService.private.post("/api/exhibitions", formData); + return res; + } catch (err) { + console.error("전시 등록 실패:", err); + throw err; + } +}; + /** * 전시 상세 정보 조회 API * @param {number} exhibitionId - 전시 ID @@ -90,6 +120,24 @@ export const getExhibitionReviewsPreview = async (exhibitionId) => { } }; +/** + * 내 전시 목록 조회 API + * @param {Object} params - 페이지네이션 파라미터 + * @param {number} params.pageNum - 페이지 번호 + * @param {number} params.pageSize - 페이지 크기 + * @param {boolean} params.fillAll - 모든 정보 채우기 여부 + * @returns {Promise} 내 전시 목록 + */ +export const getMyExhibitions = async (params = { pageNum: 1, pageSize: 3, fillAll: true }) => { + try { + const response = await APIService.private.get('/api/exhibitions/my-page', { params }); + return response; + } catch (error) { + console.error('내 전시 목록 조회 실패:', error); + throw error; + } +}; + /** * 전시 상세 정보를 위한 커스텀 훅 * @param {number} exhibitionId - 전시 ID diff --git a/src/apis/user/user.js b/src/apis/user/user.js new file mode 100644 index 0000000..b8e8bb5 --- /dev/null +++ b/src/apis/user/user.js @@ -0,0 +1,178 @@ +import { APIService } from '../axios.js'; + +/** + * 현재 로그인한 사용자 정보 조회 API + * @returns {Promise} API 응답 데이터 + */ +export const getCurrentUser = async () => { + try { + const response = await APIService.private.get('/api/users'); + return response; + } catch (error) { + console.error('사용자 정보 조회 실패:', error); + throw error; + } +}; + +/** + * 특정 사용자 정보 조회 API + * @param {string} userId - 사용자 ID + * @returns {Promise} API 응답 데이터 + */ +export const getUserById = async (userId) => { + try { + const response = await APIService.private.get(`/api/users/${userId}/creator`); + return response; + } catch (error) { + console.error('사용자 정보 조회 실패:', error); + throw error; + } +}; + +/** + * 사용자 프로필 수정 API + * @param {Object} userData - 수정할 사용자 데이터 + * @param {string} userData.nickname - 닉네임 + * @param {string} userData.introduction - 자기소개 + * @param {string} userData.code - 코드 + * @returns {Promise} API 응답 데이터 + */ +export const updateUserProfile = async (userData) => { + try { + // FormData 객체 생성 + const formData = new FormData(); + + // request라는 이름으로 JSON 데이터를 추가 + const requestData = { + nickname: userData.nickname, + code: userData.code, + introduction: userData.introduction + }; + + formData.append('request', new Blob([JSON.stringify(requestData)], { + type: 'application/json' + })); + + const response = await APIService.private.put('/api/users', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return response; + } catch (error) { + console.error('사용자 프로필 수정 실패:', error); + throw error; + } +}; + +/** + * 사용자 프로필 이미지 수정 API + * @param {File} profileImage - 프로필 이미지 파일 + * @returns {Promise} API 응답 데이터 + */ +export const updateUserProfileImage = async (profileImage) => { + try { + const formData = new FormData(); + formData.append('profileImage', profileImage); // profileImageUrl -> profileImage로 변경 + + const response = await APIService.private.put('/api/users/profile-image', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return response; + } catch (error) { + console.error('프로필 이미지 수정 실패:', error); + throw error; + } +}; + +/** + * 사용자 계정 삭제 API + * @returns {Promise} API 응답 데이터 + */ +export const deleteUserAccount = async () => { + try { + const response = await APIService.private.delete('/api/users'); + return response; + } catch (error) { + console.error('사용자 계정 삭제 실패:', error); + throw error; + } +}; + +/** + * 사용자 코드로 프로필 조회 API + * @param {string} userCode - 사용자 코드 (예: @username) + * @returns {Promise} API 응답 데이터 + */ +export const getUserProfilesByCode = async (userCode) => { + try { + const response = await APIService.private.get(`/api/users/profile?code=${userCode}`); + return response; + } catch (error) { + console.error('사용자 프로필 조회 실패:', error); + throw error; + } +}; + +/** + * 연락 정보 등록 상태 조회 API + * @returns {Promise} API 응답 데이터 + */ +export const getContactStatus = async () => { + try { + const response = await APIService.private.get('/api/users/contact/status'); + return response; + } catch (error) { + console.error('연락 정보 상태 조회 실패:', error); + throw error; + } +}; + +/** + * 연락 정보 수정 API + * @param {Object} contactData - 수정할 연락 정보 데이터 + * @param {string} contactData.email + * @param {string} contactData.instagram + * @returns {Promise} API 응답 데이터 + */ +export const updateContact = async (contactData) => { + try { + const response = await APIService.private.put('/api/users/contact', contactData); + return response; + } catch (error) { + console.error('연락 정보 수정 실패:', error); + throw error; + } +}; + +/** + * 사용자 연락 정보 조회 API + * @param {string} userId - 사용자 ID + * @returns {Promise} API 응답 데이터 + */ +export const getUserContact = async (userId) => { + try { + const response = await APIService.private.get(`/api/users/${userId}/contact`); + return response; + } catch (error) { + console.error('사용자 연락 정보 조회 실패:', error); + throw error; + } +}; + +/** + * 사용자 코드 중복 확인 API + * @param {string} code - 확인할 사용자 코드 + * @returns {Promise} API 응답 데이터 + */ +export const checkUserCode = async (code) => { + try { + const response = await APIService.private.get(`/api/users/check-code?code=${code}`); + return response; + } catch (error) { + console.error('사용자 코드 중복 확인 실패:', error); + throw error; + } +}; \ No newline at end of file diff --git a/src/components/CameraController.jsx b/src/components/CameraController.jsx index f68c195..4d3a3b1 100644 --- a/src/components/CameraController.jsx +++ b/src/components/CameraController.jsx @@ -1,6 +1,6 @@ import { useRef, useEffect } from "react"; import { useFrame, useThree } from "@react-three/fiber"; -import { Vector3, Euler } from "three"; +import { Vector3, Euler, MathUtils } from "three"; function CameraController({ isModalOpen }) { const { camera, gl } = useThree(); @@ -17,6 +17,22 @@ function CameraController({ isModalOpen }) { lastY: 0, }); + // 터치 관련 상태 추가 + const touchRef = useRef({ + isTouching: false, + startX: 0, + startY: 0, + lastX: 0, + lastY: 0, + startTime: 0, + isMoving: false, + moveThreshold: 3, // 픽셀 단위 이동 임계값 (낮춰서 더 민감하게 반응) + // 핀치 줌을 위한 상태 추가 + isPinching: false, + startDistance: 0, + lastDistance: 0, + }); + const cameraRotation = useRef({ yaw: 0, // 좌우 회전만 사용 pitch: 0, // 고정값으로 유지 @@ -29,8 +45,14 @@ function CameraController({ isModalOpen }) { zoomLevel: 1.0, // FOV 조정용 줌 레벨 }); + // 초기 위치와 회전값 저장 + const initialPosition = new Vector3(0, 2, 0); + const initialRotation = { yaw: 0, pitch: 0 }; + const initialZoom = 1.0; + const moveSpeed = 0.1; const mouseSensitivity = 0.002; + const touchSensitivity = 0.015; // 터치 감도를 적당히 조정하여 적절한 회전 속도 제공 const minZoom = 0.8; // 최대 확대 (FOV: 93.75도) const maxZoom = 1.3; // 최대 축소 (FOV: 57.7도) const fixedHeight = 2; // 고정된 카메라 높이 @@ -43,6 +65,41 @@ function CameraController({ isModalOpen }) { maxZ: 7, // 앞쪽 벽에서 1미터 떨어진 거리 }; + // 부드러운 회전을 위한 보간 변수 추가 + const smoothRotation = useRef({ + targetYaw: 0, + currentYaw: 0, + lerpFactor: 0.12, // 보간 계수 (적당히 조정하여 반응성과 부드러움의 균형) + }); + + // 초기 위치로 리셋하는 함수 + const resetToInitialPosition = () => { + // 위치 리셋 + cameraState.current.position.copy(initialPosition); + + // 회전 리셋 + cameraRotation.current.yaw = initialRotation.yaw; + cameraRotation.current.pitch = initialRotation.pitch; + + // 줌 리셋 + cameraState.current.zoomLevel = initialZoom; + camera.fov = cameraState.current.baseFOV / initialZoom; + camera.updateProjectionMatrix(); + + // 부드러운 회전 값도 리셋 + smoothRotation.current.targetYaw = initialRotation.yaw; + smoothRotation.current.currentYaw = initialRotation.yaw; + }; + + // 전역으로 리셋 함수 노출 + useEffect(() => { + window.resetCameraToInitialPosition = resetToInitialPosition; + + return () => { + delete window.resetCameraToInitialPosition; + }; + }, []); + // 모달 상태에 따른 포인터 락 관리 useEffect(() => { if (isModalOpen) { @@ -51,6 +108,10 @@ function CameraController({ isModalOpen }) { document.exitPointerLock(); } } + + // 초기화 시 smoothRotation 값 설정 + smoothRotation.current.targetYaw = cameraRotation.current.yaw; + smoothRotation.current.currentYaw = cameraRotation.current.yaw; }, [isModalOpen]); useEffect(() => { @@ -59,6 +120,11 @@ function CameraController({ isModalOpen }) { if (key in keysPressed.current) { keysPressed.current[key] = true; } + + // R키를 누르면 초기 위치로 리셋 + if (key === 'r') { + resetToInitialPosition(); + } }; const handleKeyUp = (event) => { @@ -93,9 +159,9 @@ function CameraController({ isModalOpen }) { const deltaX = event.movementX * mouseSensitivity; // deltaY는 사용하지 않음 (상하 시점 변경 비활성화) - cameraRotation.current.yaw -= deltaX; - // pitch는 고정값으로 유지 (0도) - cameraRotation.current.pitch = 0; + // 마우스로도 부드러운 회전 적용 + const targetYaw = cameraRotation.current.yaw - deltaX; + smoothRotation.current.targetYaw = targetYaw; } }; @@ -114,6 +180,148 @@ function CameraController({ isModalOpen }) { document.pointerLockElement === gl.domElement; }; + // 터치 이벤트 핸들러 추가 + const handleTouchStart = (event) => { + if (isModalOpen) return; + + if (event.touches.length === 1) { + // 단일 터치 + const touch = event.touches[0]; + touchRef.current.isTouching = true; + touchRef.current.isPinching = false; + touchRef.current.startX = touch.clientX; + touchRef.current.startY = touch.clientY; + touchRef.current.lastX = touch.clientX; + touchRef.current.lastY = touch.clientY; + touchRef.current.startTime = Date.now(); + touchRef.current.isMoving = false; + } else if (event.touches.length === 2) { + // 핀치 제스처 시작 + touchRef.current.isPinching = true; + touchRef.current.isTouching = false; + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + const distance = Math.sqrt( + Math.pow(touch1.clientX - touch2.clientX, 2) + + Math.pow(touch1.clientY - touch2.clientY, 2) + ); + touchRef.current.startDistance = distance; + touchRef.current.lastDistance = distance; + } + + event.preventDefault(); + }; + + const handleTouchMove = (event) => { + if (isModalOpen) return; + + if (event.touches.length === 1 && touchRef.current.isTouching) { + // 단일 터치 드래그 + const touch = event.touches[0]; + const deltaX = touch.clientX - touchRef.current.lastX; + const deltaY = touch.clientY - touchRef.current.lastY; + + // 이동 거리 계산 + const moveDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (moveDistance > touchRef.current.moveThreshold) { + touchRef.current.isMoving = true; + + // 터치 드래그로 카메라 회전 (부드럽게) + const targetYaw = cameraRotation.current.yaw + deltaX * touchSensitivity; + smoothRotation.current.targetYaw = targetYaw; + + // 터치 드래그로 줌 (수직 드래그) - 더 민감하게 + if (Math.abs(deltaY) > 5) { // 수직 이동이 충분할 때만 줌 적용 + const zoomSpeed = 0.005; // 줌 속도 조정 + const zoomFactor = deltaY > 0 ? (1 - zoomSpeed * Math.abs(deltaY)) : (1 + zoomSpeed * Math.abs(deltaY)); + const newZoomLevel = cameraState.current.zoomLevel * zoomFactor; + + if (newZoomLevel >= minZoom && newZoomLevel <= maxZoom) { + cameraState.current.zoomLevel = newZoomLevel; + camera.fov = cameraState.current.baseFOV / newZoomLevel; + camera.updateProjectionMatrix(); + } + } + } + + touchRef.current.lastX = touch.clientX; + touchRef.current.lastY = touch.clientY; + } else if (event.touches.length === 2 && touchRef.current.isPinching) { + // 핀치 줌 + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + const distance = Math.sqrt( + Math.pow(touch1.clientX - touch2.clientX, 2) + + Math.pow(touch1.clientY - touch2.clientY, 2) + ); + + const deltaDistance = distance - touchRef.current.lastDistance; + if (Math.abs(deltaDistance) > 5) { + const pinchZoomSpeed = 0.01; + const zoomFactor = deltaDistance > 0 ? (1 + pinchZoomSpeed) : (1 - pinchZoomSpeed); + const newZoomLevel = cameraState.current.zoomLevel * zoomFactor; + + if (newZoomLevel >= minZoom && newZoomLevel <= maxZoom) { + cameraState.current.zoomLevel = newZoomLevel; + camera.fov = cameraState.current.baseFOV / newZoomLevel; + camera.updateProjectionMatrix(); + } + } + + touchRef.current.lastDistance = distance; + } + + event.preventDefault(); + }; + + const handleTouchEnd = (event) => { + if (isModalOpen) return; + + if (touchRef.current.isTouching) { + // 단일 터치 종료 + const touchDuration = Date.now() - touchRef.current.startTime; + const moveDistance = Math.sqrt( + Math.pow(touchRef.current.lastX - touchRef.current.startX, 2) + + Math.pow(touchRef.current.lastY - touchRef.current.startY, 2) + ); + + // 짧은 터치 + 적은 이동 = 클릭으로 인식하여 이동 + if (touchDuration < 300 && moveDistance < 20 && !touchRef.current.isMoving) { + // 터치 위치에 따른 이동 방향 결정 + const centerX = gl.domElement.clientWidth / 2; + const centerY = gl.domElement.clientHeight / 2; + const touchX = touchRef.current.startX; + const touchY = touchRef.current.startY; + + // 기본적으로 앞으로 이동 (W키) + keysPressed.current.w = true; + setTimeout(() => { keysPressed.current.w = false; }, 300); + + // 사이드 영역 터치 시 추가로 옆으로 이동 + if (touchX < centerX - 80) { + // 왼쪽 영역 - A키 (왼쪽 이동) + keysPressed.current.a = true; + setTimeout(() => { keysPressed.current.a = false; }, 200); + } else if (touchX > centerX + 80) { + // 오른쪽 영역 - D키 (오른쪽 이동) + keysPressed.current.d = true; + setTimeout(() => { keysPressed.current.d = false; }, 200); + } + } + + touchRef.current.isTouching = false; + touchRef.current.isMoving = false; + } + + if (touchRef.current.isPinching) { + // 핀치 제스처 종료 + touchRef.current.isPinching = false; + } + + event.preventDefault(); + }; + // 이벤트 리스너 등록 window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); @@ -121,6 +329,11 @@ function CameraController({ isModalOpen }) { gl.domElement.addEventListener("click", handleClick); gl.domElement.addEventListener("mousemove", handleMouseMove); document.addEventListener("pointerlockchange", handlePointerLockChange); + + // 터치 이벤트 리스너 등록 + gl.domElement.addEventListener("touchstart", handleTouchStart, { passive: false }); + gl.domElement.addEventListener("touchmove", handleTouchMove, { passive: false }); + gl.domElement.addEventListener("touchend", handleTouchEnd, { passive: false }); // 정리 함수 return () => { @@ -129,10 +342,12 @@ function CameraController({ isModalOpen }) { gl.domElement.removeEventListener("wheel", handleWheel); gl.domElement.removeEventListener("click", handleClick); gl.domElement.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener( - "pointerlockchange", - handlePointerLockChange - ); + document.removeEventListener("pointerlockchange", handlePointerLockChange); + + // 터치 이벤트 리스너 제거 + gl.domElement.removeEventListener("touchstart", handleTouchStart); + gl.domElement.removeEventListener("touchmove", handleTouchMove); + gl.domElement.removeEventListener("touchend", handleTouchEnd); }; }, [ camera, @@ -140,6 +355,7 @@ function CameraController({ isModalOpen }) { minZoom, maxZoom, mouseSensitivity, + touchSensitivity, fixedHeight, isModalOpen, ]); @@ -148,6 +364,17 @@ function CameraController({ isModalOpen }) { // 모달이 열려있으면 카메라 조작 중단 if (isModalOpen) return; + // 부드러운 회전 적용 + smoothRotation.current.currentYaw = MathUtils.lerp( + smoothRotation.current.currentYaw, + smoothRotation.current.targetYaw, + smoothRotation.current.lerpFactor + ); + cameraRotation.current.yaw = smoothRotation.current.currentYaw; + + // pitch는 고정값으로 유지 (0도) + cameraRotation.current.pitch = 0; + // 카메라 회전 적용 (Y축 회전만, pitch는 0으로 고정) camera.rotation.set( 0, // pitch 고정 (상하 시점 변경 없음) @@ -214,6 +441,7 @@ function CameraController({ isModalOpen }) { camera.position.copy(cameraState.current.position); }); + // 초기 위치로 돌아가기 버튼 렌더링 return null; } diff --git a/src/components/ControlsGuideModal.jsx b/src/components/ControlsGuideModal.jsx new file mode 100644 index 0000000..f718a78 --- /dev/null +++ b/src/components/ControlsGuideModal.jsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from 'react'; + +function ControlsGuideModal() { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + // 2초 후 자동으로 숨김 + const timer = setTimeout(() => { + setIsVisible(false); + }, 2000); + + return () => clearTimeout(timer); + }, []); + + if (!isVisible) return null; + + return ( +
현대 미술의 새로운 시선
+ {/* 초기 위치로 돌아가기 버튼 */} +더 많은 작품을 불러오는 중...
@@ -278,9 +314,9 @@ export default function ArtworkList({ ) : (+ {`${exhibition.startDate.slice(2, 4)}.${exhibition.startDate.slice(5, 7)}.${exhibition.startDate.slice(8, 10)} - ${exhibition.endDate.slice(2, 4)}.${exhibition.endDate.slice(5, 7)}.${exhibition.endDate.slice(8, 10)}`} +
+ )}{exhibition.description}
{exhibition.date}
-+ {exhibition.startDate && exhibition.endDate ? + `${formatDate(exhibition.startDate)} - ${formatDate(exhibition.endDate)}` : + '등록 완료' + } +
++
{user.title}
+크리에이터의 전시장
더 많은 작품을 불러오는 중...
diff --git a/src/components/museum/pages/ArtworkUploadPage.jsx b/src/components/museum/pages/ArtworkUploadPage.jsx index 524b23e..dd19cf2 100644 --- a/src/components/museum/pages/ArtworkUploadPage.jsx +++ b/src/components/museum/pages/ArtworkUploadPage.jsx @@ -1,8 +1,7 @@ import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import useArtworkDraftStore from '@museum/services/artworkDraftStore'; -import useArtworkStore from '@museum/services/artworkStore'; -import useUserStore from '@/stores/userStore'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { createPiece } from '@apis/museum/artwork'; +import { getCurrentUser } from '@apis/user/user'; import ArtworkModal from '@museum/components/artwork/ArtworkModal'; import chevronLeft from '@/assets/museum/chevron-left.png'; import cameraIcon from '@/assets/user/camera.png'; @@ -11,10 +10,15 @@ import styles from './artworkUploadPage.module.css'; export default function ArtworkUploadPage() { const navigate = useNavigate(); - const { saveDraft } = useArtworkDraftStore(); - const { addArtwork } = useArtworkStore(); - const { user } = useUserStore(); + const location = useLocation(); + // 전시 등록에서 넘어온 경우 draft 데이터 받기 + const draft = location.state?.draft; + const fromExhibition = location.state?.fromExhibition; + const isThumbnail = location.state?.isThumbnail; + const isChangeMode = location.state?.isChangeMode; + const returnTo = location.state?.returnTo; + const [user, setUser] = useState(null); const [formData, setFormData] = useState({ title: '', description: '', @@ -29,13 +33,146 @@ export default function ArtworkUploadPage() { const [errorMessage, setErrorMessage] = useState(''); const [showErrors, setShowErrors] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [validationTimeout, setValidationTimeout] = useState(null); + const [mainImageUrl, setMainImageUrl] = useState(''); + const [detailImageUrls, setDetailImageUrls] = useState([]); + + // 유저 정보 가져오기 + useEffect(() => { + const fetchUser = async () => { + try { + const response = await getCurrentUser(); + setUser(response.data); + } catch (error) { + console.error('유저 정보 조회 실패:', error); + // 에러가 발생해도 페이지는 계속 사용할 수 있도록 함 + } + }; + + fetchUser(); + }, []); + + // 메인 이미지 URL 생성 및 정리 + useEffect(() => { + if (formData.mainImage) { + const url = URL.createObjectURL(formData.mainImage); + setMainImageUrl(url); + + // 컴포넌트 언마운트 시 URL 정리 + return () => URL.revokeObjectURL(url); + } else { + setMainImageUrl(''); + } + }, [formData.mainImage]); + + // 디테일 이미지 URL 생성 및 정리 + useEffect(() => { + const urls = []; + formData.detailImages.forEach((image, index) => { + if (image) { + const url = URL.createObjectURL(image); + urls[index] = url; + } + }); + + setDetailImageUrls(urls); + + // 컴포넌트 언마운트 시 모든 URL 정리 + return () => { + urls.forEach(url => { + if (url) URL.revokeObjectURL(url); + }); + }; + }, [formData.detailImages]); + + // 입력 완료 후 1초 뒤에 에러 메시지만 숨기기 + useEffect(() => { + if (validationTimeout) { + clearTimeout(validationTimeout); + } + + const timeout = setTimeout(() => { + // 입력이 완료되면 에러 메시지 숨기기 + if (showErrors) { + setShowErrors(false); + setErrorMessage(''); + } + }, 1000); + + setValidationTimeout(timeout); + + return () => { + if (timeout) { + clearTimeout(timeout); + } + }; + }, [formData.title, formData.description, showErrors]); + + // 폼 검증 함수 + const validateForm = () => { + let hasError = false; + let errorMsg = ''; + + // 1순위: 메인 이미지 없음 + if (!formData.mainImage) { + errorMsg = '메인 이미지를 등록해주세요'; + hasError = true; + } + // 2순위: 작품 소개 500자 이상 + else if (formData.description && formData.description.length > 500) { + errorMsg = '작품 소개는 최대 500자까지만 가능해요'; + hasError = true; + } + // 3순위: 작품명 없음 + else if (!formData.title || !formData.title.trim()) { + errorMsg = '작품명을 작성해주세요'; + hasError = true; + } + // 4순위: 작품 소개 없음 + else if (!formData.description || !formData.description.trim()) { + errorMsg = '작품 소개를 작성해주세요'; + hasError = true; + } + + // 에러가 있을 때만 상태 업데이트 + if (hasError) { + setErrorMessage(errorMsg); + setShowErrors(true); + + // 에러 메시지를 3초 후에 자동으로 숨기기 + setTimeout(() => { + setShowErrors(false); + setErrorMessage(''); + }, 3000); + } + + return hasError; // 에러 여부 반환 + }; + + // 로컬 draft 저장소 (localStorage 사용) + const saveDraft = (data) => { + try { + localStorage.setItem('artworkDraft', JSON.stringify(data)); + console.log('임시저장 완료:', data); + } catch (error) { + console.error('임시저장 실패:', error); + } + }; const handleBack = () => { // 작성 중인 내용이 있으면 취소 모달 표시 if (formData.title || formData.description || formData.mainImage || formData.detailImages.length > 0) { setModal({ isOpen: true, type: 'cancel' }); } else { - navigate('/artwork/my'); + // 전시 등록에서 넘어온 경우 draft 데이터와 함께 돌아가기 + if (fromExhibition && returnTo === 'exhibition-upload') { + navigate('/exhibition/upload', { + state: { draft: draft } + }); + } else { + navigate('/artwork/my'); + } } }; @@ -85,38 +222,11 @@ export default function ArtworkUploadPage() { const handleSubmit = (e) => { e.preventDefault(); - let hasError = false; + // 즉시 검증 실행하고 에러 여부 확인 + const hasError = validateForm(); - // 1순위: 메인 이미지 없음 - if (!formData.mainImage) { - setErrorMessage('메인 이미지를 등록해주세요'); - hasError = true; - } - // 2순위: 작품 소개 500자 이상 - else if (formData.description && formData.description.length > 500) { - setErrorMessage('작품 소개는 최대 500자까지만 가능해요'); - hasError = true; - } - // 3순위: 작품명 없음 - else if (!formData.title || !formData.title.trim()) { - setErrorMessage('작품명을 작성해주세요'); - hasError = true; - } - // 4순위: 작품 소개 없음 - else if (!formData.description || !formData.description.trim()) { - setErrorMessage('작품 소개를 작성해주세요'); - hasError = true; - } - - setShowErrors(hasError); - - // 에러가 있으면 에러 메시지 표시 + // 에러가 있으면 제출 중단 if (hasError) { - // 에러 메시지를 3초 후에 자동으로 숨기기 - setTimeout(() => { - setShowErrors(false); - setErrorMessage(''); - }, 3000); return; } @@ -124,34 +234,51 @@ export default function ArtworkUploadPage() { setModal({ isOpen: true, type: 'register' }); }; - const handleRegisterConfirm = () => { - // 실제 작품 등록 로직 - const newArtwork = { - title: formData.title, - description: formData.description, - image: formData.mainImage ? URL.createObjectURL(formData.mainImage) : null, - // 추가 정보들 - artistName: user.name, - artistNickname: user.nickname, - // 이미지 파일들 (실제 구현에서는 서버에 업로드 후 URL 사용) - mainImage: formData.mainImage, - detailImages: formData.detailImages.filter(img => img !== null) - }; + const handleRegisterConfirm = async () => { + if (isSubmitting) return; - // store에 작품 등록 - const registeredArtwork = addArtwork(newArtwork); - console.log('작품 등록 완료:', registeredArtwork); + setIsSubmitting(true); - // 등록 완료 모달 표시 - setModal({ isOpen: true, type: 'complete' }); + try { + // API를 사용하여 작품 등록 + const response = await createPiece(formData, 'APPLICATION'); + + if (response?.success === true && (response?.code === 200 || response?.code === 201)) { + console.log('작품 등록 완료:', response.data); + + // 등록 완료 모달 표시 + setModal({ isOpen: true, type: 'complete' }); + } else { + throw new Error('작품 등록에 실패했습니다.'); + } + } catch (error) { + console.error('작품 등록 실패:', error); + setErrorMessage('작품 등록에 실패했습니다. 다시 시도해주세요.'); + setShowErrors(true); + + // 에러 메시지를 3초 후에 자동으로 숨기기 + setTimeout(() => { + setShowErrors(false); + setErrorMessage(''); + }, 3000); + } finally { + setIsSubmitting(false); + } }; const handleCompleteConfirm = () => { // 등록 완료시 모달 닫고 페이지 이동 setModal({ isOpen: false, type: null }); - // 내 작품 페이지로 바로 이동 - navigate('/artwork/my'); + // 전시 등록에서 넘어온 경우 draft 데이터와 함께 돌아가기 + if (fromExhibition && returnTo === 'exhibition-upload') { + navigate('/exhibition/upload', { + state: { draft: draft } + }); + } else { + // 내 작품 페이지로 바로 이동 + navigate('/artwork/my'); + } }; const handleCancelConfirm = () => { @@ -169,19 +296,47 @@ export default function ArtworkUploadPage() { setModal({ isOpen: false, type: null }); }; - const handleSave = () => { - // 수동 임시 저장 - saveDraft(formData); + const handleSave = async () => { + // 임시저장 중이면 중단 + if (isSubmitting) return; - // 임시저장 완료 메시지 표시 - setErrorMessage('임시 저장이 완료되었어요'); - setShowErrors(true); + setIsSubmitting(true); - // 메시지를 3초 후에 자동으로 숨기기 - setTimeout(() => { - setShowErrors(false); - setErrorMessage(''); - }, 3000); + try { + // API를 사용하여 임시저장 (DRAFT) + const response = await createPiece(formData, 'DRAFT'); + + if (response?.success === true && (response?.code === 200 || response?.code === 201)) { + console.log('임시저장 완료:', response.data); + + // 로컬 draft도 업데이트 + saveDraft(formData); + + // 임시저장 완료 메시지 표시 + setErrorMessage('임시 저장이 완료되었어요'); + setShowErrors(true); + + // 메시지를 3초 후에 자동으로 숨기기 + setTimeout(() => { + setShowErrors(false); + setErrorMessage(''); + }, 3000); + } else { + throw new Error('임시저장에 실패했습니다.'); + } + } catch (error) { + console.error('임시저장 실패:', error); + setErrorMessage('임시저장에 실패했습니다. 다시 시도해주세요.'); + setShowErrors(true); + + // 에러 메시지를 3초 후에 자동으로 숨기기 + setTimeout(() => { + setShowErrors(false); + setErrorMessage(''); + }, 3000); + } finally { + setIsSubmitting(false); + } }; return ( @@ -214,23 +369,23 @@ export default function ArtworkUploadPage() {- {selectedParticipants.length > 0 - ? "프로필을 누르면 등록이 취소됩니다" - : "프로필을 누르면 등록이 완료됩니다" - } -
- - {/* 참여자 목록 */} -+ {selectedParticipants.length === 0 + ? '프로필을 누르면 등록이 완료됩니다' + : `프로필을 누르면 등록이 취소됩니다 (${selectedParticipants.length})` + } +
+ )} + + {/* 검색 결과 목록 */} + {console.log('렌더링 조건 확인:', { isSearching, searchResultsLength: searchResults.length, searchResults })} + {searchResults.length > 0 && ( +
+ 전시 썸네일을 등록해주세요
+ (필수)
+
도로명+ 건물번호
-남대문로 9길 40
-지역명(동/리) + 번지
-중구 다동 155
-지역명(동/리)+ 건물명
-분당 주공
-
+ 공유 라이브러리 작품을 등록해주세요
+ (선택)
+
+ 공유 라이브러리 작품을 등록해주세요
+ (선택)
+
+ 공유 라이브러리 썸네일을 등록해주세요
+ (필수)
+
이메일
인스타그램
+
+ )}
+
+ {console.log('현재 사용자 상태:', user)}
+
+ )}
- {user.bio || "안녕하세요. 아름다운 바다 그림을 통해 많은 사람들에게 행복을 주고 싶은 크리에이터입니다."} + {user.introduction || "자기소개를 등록해주세요."}
이메일 {user.email}
인스타그램 @{user.instagram || user.nickname}