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 ( +
+

+ 🎮 전시장 조작법 +

+ +
+
+ 🖱️ 마우스: 드래그로 시점 회전 +
+ +
+ ⌨️ 키보드: WASD로 이동 +
+ +
+ 📱 터치: 슬라이드로 시점 회전, 핀치로 줌 +
+ +
+ 🖱️ 휠: 줌 인/아웃 +
+ +
+ 🏠 R키: 처음 위치로 돌아가기 +
+ +
+ 👆 클릭: 작품 정보 보기 +
+
+ +
+ ⏱️ 2초 후 자동으로 사라집니다 +
+
+ ); +} + +export default ControlsGuideModal; diff --git a/src/components/ControlsInfoModal.jsx b/src/components/ControlsInfoModal.jsx new file mode 100644 index 0000000..5221230 --- /dev/null +++ b/src/components/ControlsInfoModal.jsx @@ -0,0 +1,80 @@ +import React, { useEffect, useState } from 'react'; + +function ControlsInfoModal() { + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + // 3초 후 자동으로 숨김 + const timer = setTimeout(() => { + setIsVisible(false); + }, 3000); + + return () => clearTimeout(timer); + }, []); + + if (!isVisible) return null; + + return ( +
+

+ 🎮 조작법 +

+ +
+
+ 📱 터치: 앞으로 이동 +
+ +
+ ↔️ 슬라이드: 화면 회전 +
+ +
+ 👆 핀치: 확대 / 축소 +
+
+ +
+ ⏱️ 3초 후 자동으로
사라집니다 +
+
+ ); +} + +export default ControlsInfoModal; diff --git a/src/components/Gallery3D.css b/src/components/Gallery3D.css index 9d52c2f..f8e162d 100644 --- a/src/components/Gallery3D.css +++ b/src/components/Gallery3D.css @@ -20,20 +20,23 @@ z-index: 10; color: white; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + max-width: calc(100vw - 200px); /* 오른쪽 버튼 공간 확보 */ } .gallery-header h1 { - font-size: 2.5rem; + font-size: 2rem; /* 크기 줄임 */ margin: 0; font-weight: 700; letter-spacing: -0.02em; + line-height: 1.2; } .gallery-header p { - font-size: 1.1rem; + font-size: 1rem; /* 크기 줄임 */ margin: 5px 0 0 0; opacity: 0.9; font-weight: 300; + line-height: 1.3; } .canvas-container { @@ -80,6 +83,63 @@ } } +@keyframes fadeIn { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.9); + } + 100% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes slideInUp { + 0% { + opacity: 0; + transform: translate(-50%, -30%); + } + 100% { + opacity: 1; + transform: translate(-50%, -50%); + } +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .gallery-header { + margin-top: 10px; + max-width: calc(100vw - 180px); + top: 15px; + left: 15px; + } + + .gallery-header h1 { + font-size: 1.6rem; + } + + .gallery-header p { + font-size: 0.9rem; + } +} + +@media (max-width: 480px) { + .gallery-header { + margin-top: 10px; + max-width: calc(100vw - 160px); + top: 10px; + left: 10px; + } + + .gallery-header h1 { + font-size: 1.3rem; + } + + .gallery-header p { + font-size: 0.8rem; + } +} + .gallery-footer { position: absolute; bottom: 20px; @@ -244,15 +304,6 @@ margin-top: auto; } -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - @keyframes modalSlideIn { from { opacity: 0; @@ -263,81 +314,3 @@ transform: scale(1) translateY(0); } } - -/* 반응형 디자인 */ -@media (max-width: 768px) { - .gallery-header h1 { - font-size: 1.8rem; - } - - .gallery-header p { - font-size: 1rem; - } - - .controls-info { - padding: 8px 16px; - font-size: 0.8rem; - } - - .artwork-modal { - width: 95vw; - max-height: 90vh; - } - - .artwork-modal-content { - flex-direction: column; - } - - .artwork-image { - max-width: none; - max-height: 250px; - } - - .artwork-info { - padding: 20px; - } - - .artwork-modal-header { - padding: 15px 20px; - } - - .artwork-modal-header h2 { - font-size: 1.3rem; - } -} - -@media (max-width: 480px) { - .gallery-header { - top: 10px; - left: 10px; - } - - .gallery-header h1 { - font-size: 1.5rem; - } - - .gallery-header p { - font-size: 0.9rem; - } - - .gallery-footer { - bottom: 10px; - } - - .controls-info { - padding: 6px 12px; - font-size: 0.7rem; - } - - .artwork-modal-header h2 { - font-size: 1.2rem; - } - - .artwork-info { - padding: 15px; - } - - .artist-info h3 { - font-size: 1.1rem; - } -} diff --git a/src/components/Gallery3D.jsx b/src/components/Gallery3D.jsx index 4f56aaf..1ae6c53 100644 --- a/src/components/Gallery3D.jsx +++ b/src/components/Gallery3D.jsx @@ -3,6 +3,8 @@ import { Environment } from "@react-three/drei"; import { Suspense, useState } from "react"; import Exhibition from "./Exhibition"; import CameraController from "./CameraController"; +import ResetCameraButton from "./ResetCameraButton"; +import ControlsInfoModal from "./ControlsInfoModal"; import "./Gallery3D.css"; function LoadingFallback() { @@ -32,6 +34,12 @@ function Gallery3D() {

현대 미술의 새로운 시선

+ {/* 초기 위치로 돌아가기 버튼 */} + + + {/* 조작법 정보 모달 */} + +
}> )} -
-
-

- 🖱️ 클릭해서 시점 조작 활성화 | ⌨️ WASD로 이동 | 🔍 휠로 확대/축소 | - ✋ 작품 클릭으로 정보 보기 -

-
-
); } diff --git a/src/components/ResetCameraButton.jsx b/src/components/ResetCameraButton.jsx new file mode 100644 index 0000000..2ce49ad --- /dev/null +++ b/src/components/ResetCameraButton.jsx @@ -0,0 +1,41 @@ +import React from 'react'; + +function ResetCameraButton() { + const handleReset = () => { + if (window.resetCameraToInitialPosition) { + window.resetCameraToInitialPosition(); + } + }; + + return ( +
{ + e.target.style.background = 'rgba(0, 0, 0, 0.9)'; + }} + onMouseLeave={(e) => { + e.target.style.background = 'rgba(0, 0, 0, 0.7)'; + }} + onClick={handleReset} + title="R키를 눌러도 초기 위치로 돌아갑니다" + > + 🏠 처음위치로 +
+ ); +} + +export default ResetCameraButton; diff --git a/src/components/museum/components/artwork/ArtworkCard.jsx b/src/components/museum/components/artwork/ArtworkCard.jsx index 2ccf324..d7b9133 100644 --- a/src/components/museum/components/artwork/ArtworkCard.jsx +++ b/src/components/museum/components/artwork/ArtworkCard.jsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; +import { getImageUrl, getStatusStyle } from '@apis/museum/artwork'; import styles from './artworkCard.module.css'; import checkIcon from '@/assets/museum/check.png'; @@ -14,11 +16,24 @@ export default function ArtworkCard({ onSelect, checkboxSize = 'normal' // 'normal' (24x24) 또는 'large' (36x36) }) { + const [imageError, setImageError] = useState(false); + + const imageUrl = getImageUrl(artwork); + + const handleImageError = () => { + setImageError(true); + }; + const handleClick = () => { + if (onClick) { + onClick(artwork); + } + }; + + const handleCheckboxClick = (e) => { + e.stopPropagation(); if (isEditMode && onSelect) { onSelect(artwork); - } else if (!isEditMode && onClick) { - onClick(artwork); } }; @@ -29,35 +44,31 @@ export default function ArtworkCard({ } }; - const getStatusStyle = (status) => { - switch (status) { - case '전시 중': - return styles.statusExhibiting; - case '미승인': - return styles.statusRejected; - case '승인 대기': - return styles.statusPending; - default: - return styles.statusDefault; - } - }; - if (layoutMode === 'grid') { return (
- {showStatus && artwork.status && ( -
- {artwork.status} + {!imageUrl || imageError ? ( +
+ 이미지 없음 +
+ ) : null} + + {showStatus && artwork.progressStatus === 'ON_DISPLAY' && ( +
+ 전시 중
)} {isEditMode && ( -
+
{isSelected && ( 선택됨 )} @@ -75,16 +86,25 @@ export default function ArtworkCard({
- {showStatus && artwork.status && ( -
- {artwork.status} + {!imageUrl || imageError ? ( +
+ 이미지 없음 +
+ ) : null} + + {showStatus && artwork.progressStatus === 'ON_DISPLAY' && ( +
+ 전시 중
)} {isEditMode && ( -
+
{isSelected && ( 선택됨 )} diff --git a/src/components/museum/components/artwork/ArtworkList.jsx b/src/components/museum/components/artwork/ArtworkList.jsx index 260d6d1..440b74f 100644 --- a/src/components/museum/components/artwork/ArtworkList.jsx +++ b/src/components/museum/components/artwork/ArtworkList.jsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import ArtworkCard from './ArtworkCard'; import ArtworkFilter from './ArtworkFilter'; -import useArtworkStore from '@museum/services/artworkStore'; +import { deletePieces, getArtworkTitle, getArtworkDescription } from '@apis/museum/artwork'; +import useUserStore from '@/stores/userStore'; import BackToTopButton from '@/components/common/BackToTopButton'; import chevronLeft from '@/assets/museum/chevron-left.png'; import arrowDown from '@/assets/museum/arrow-down.svg'; @@ -17,31 +18,32 @@ export default function ArtworkList({ showDraftButton = false, onDraftClick, isLibraryMode = false, - libraryTitle = "작품 목록" + libraryTitle = "작품 목록", + title, // 커스텀 제목 prop 추가 + artworks = [], + loading = false, + hasMore = false, + onLoadMore, + onArtworkDeleted // 새로운 prop 추가 }) { - // Zustand 스토어에서 상태 가져오기 - const { - artworks, - layoutMode, - searchKeyword, - isLoadingMore, - setLayoutMode, - setSearchKeyword, - loadMoreArtworks, - hasMore, - getFilteredArtworks, - deleteArtwork - } = useArtworkStore(); - + // 로컬 상태로 관리 + const [layoutMode, setLayoutMode] = useState('grid'); + const [searchKeyword, setSearchKeyword] = useState(''); const [isEditMode, setIsEditMode] = useState(false); const [showScrollHeader, setShowScrollHeader] = useState(false); const [selectedArtworks, setSelectedArtworks] = useState(new Set()); + const [isDeleting, setIsDeleting] = useState(false); + const observerRef = useRef(); const loadingRef = useRef(); const headerRef = useRef(); - // 필터된 작품 목록 - const filteredArtworks = getFilteredArtworks(); + // 검색어로 필터링된 작품 목록 + const filteredArtworks = artworks.filter(artwork => + !searchKeyword || + artwork.title?.toLowerCase().includes(searchKeyword.toLowerCase()) || + artwork.description?.toLowerCase().includes(searchKeyword.toLowerCase()) + ); // 스크롤 감지 로직 useEffect(() => { @@ -58,17 +60,17 @@ export default function ArtworkList({ // 무한 스크롤 구현 const lastArtworkElementRef = useCallback((node) => { - if (isLoadingMore) return; + if (loading || !hasMore) return; if (observerRef.current) observerRef.current.disconnect(); observerRef.current = new IntersectionObserver(entries => { - if (entries[0].isIntersecting && hasMore()) { - loadMoreArtworks(); + if (entries[0].isIntersecting && hasMore && onLoadMore) { + onLoadMore(); } }); if (node) observerRef.current.observe(node); - }, [isLoadingMore, hasMore, loadMoreArtworks]); + }, [loading, hasMore, onLoadMore]); const handleEditModeToggle = () => { setIsEditMode(!isEditMode); @@ -93,36 +95,70 @@ export default function ArtworkList({ }; const handleArtworkSelection = (artwork) => { + console.log('작품 선택 시도:', artwork.pieceId, artwork.title); + console.log('현재 선택된 작품들:', Array.from(selectedArtworks)); + const newSelected = new Set(selectedArtworks); - if (newSelected.has(artwork.id)) { - newSelected.delete(artwork.id); + if (newSelected.has(artwork.pieceId)) { + newSelected.delete(artwork.pieceId); + console.log('작품 선택 해제:', artwork.pieceId); } else { - newSelected.add(artwork.id); + newSelected.add(artwork.pieceId); + console.log('작품 선택 추가:', artwork.pieceId); } + + console.log('새로운 선택된 작품들:', Array.from(newSelected)); setSelectedArtworks(newSelected); }; - const handleDeleteSelected = () => { - if (selectedArtworks.size === 0) return; + const handleDeleteSelected = async () => { + if (selectedArtworks.size === 0 || isDeleting) return; const count = selectedArtworks.size; if (confirm(`선택한 ${count}개 작품을 정말 삭제하시겠습니까?`)) { - // 선택된 작품들을 실제로 삭제 - Array.from(selectedArtworks).forEach(artworkId => { - deleteArtwork(artworkId); - }); + setIsDeleting(true); - console.log('선택된 작품들 삭제 완료:', Array.from(selectedArtworks)); - setSelectedArtworks(new Set()); + try { + // 선택된 작품들을 한 번에 삭제 + const pieceIds = Array.from(selectedArtworks); + const response = await deletePieces(pieceIds); + + if (response?.status === 200) { + console.log('선택된 작품들 삭제 완료:', pieceIds); + // 부모 컴포넌트에 삭제 완료 알림 + if (onArtworkDeleted) { + onArtworkDeleted(pieceIds); + } + } else { + console.warn('작품들 삭제에 실패했습니다.'); + } + + setSelectedArtworks(new Set()); + } catch (error) { + console.error('작품 삭제 중 오류 발생:', error); + } finally { + setIsDeleting(false); + } } }; - const handleDeleteArtwork = (artwork) => { - if (confirm(`"${artwork.title}" 작품을 정말 삭제하시겠습니까?`)) { - // 여기서 실제 삭제 로직을 구현할 수 있습니다. - console.log('작품 삭제:', artwork); - // 예시: artworkStore에 deleteArtwork 함수가 있다면 - deleteArtwork(artwork.id); + const handleDeleteArtwork = async (artwork) => { + const safeTitle = getArtworkTitle(artwork.title, '이 작품'); + if (confirm(`"${safeTitle}" 작품을 정말 삭제하시겠습니까?`)) { + try { + const response = await deletePieces([artwork.pieceId]); + if (response?.status === 200 || response?.status === 204) { + console.log('작품 삭제 완료:', artwork); + // 부모 컴포넌트에 삭제 완료 알림 + if (onArtworkDeleted) { + onArtworkDeleted([artwork.pieceId]); + } + } else { + console.error('작품 삭제 실패:', artwork); + } + } catch (error) { + console.error('작품 삭제 중 오류 발생:', error); + } } }; @@ -180,7 +216,7 @@ export default function ArtworkList({ back )} -

{isLibraryMode ? libraryTitle : '내 작품'}

+

{title || (isLibraryMode ? libraryTitle : '내 작품')}

{showDraftButton && ( @@ -209,7 +245,7 @@ export default function ArtworkList({ back )} -

{isLibraryMode ? libraryTitle : '내 작품'}

+

{title || (isLibraryMode ? libraryTitle : '내 작품')}

)} @@ -242,7 +278,7 @@ export default function ArtworkList({ const isLast = index === filteredArtworks.length - 1; return (
@@ -264,7 +300,7 @@ export default function ArtworkList({ )} {/* 더 로딩 중 표시 */} - {isLoadingMore && ( + {loading && (

더 많은 작품을 불러오는 중...

@@ -278,9 +314,9 @@ export default function ArtworkList({ ) : (
- - {/* 숨겨진 파일 입력 요소 */} -
); } diff --git a/src/components/museum/components/exhibition/ExhibitionCard.jsx b/src/components/museum/components/exhibition/ExhibitionCard.jsx index 48ee8f1..31a967a 100644 --- a/src/components/museum/components/exhibition/ExhibitionCard.jsx +++ b/src/components/museum/components/exhibition/ExhibitionCard.jsx @@ -47,12 +47,12 @@ export default function ExhibitionCard({
- {showStatus && exhibition.status && ( -
- {exhibition.status} + {showStatus && exhibition.status === 'ONGOING' && ( +
+ 전시 중
)} {isEditMode && ( @@ -74,12 +74,12 @@ export default function ExhibitionCard({
- {showStatus && exhibition.status && ( -
- {exhibition.status} + {showStatus && exhibition.status === 'ONGOING' && ( +
+ 전시 중
)} {isEditMode && ( @@ -90,17 +90,16 @@ export default function ExhibitionCard({
)}
- - {showDate && exhibition.createdAt && ( -
- {exhibition.createdAt} -
- )}
{showDescription && (

{exhibition.title}

+ {showDate && exhibition.startDate && exhibition.endDate && ( +

+ {`${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}

)} diff --git a/src/components/museum/components/exhibition/ExhibitionDatePicker.jsx b/src/components/museum/components/exhibition/ExhibitionDatePicker.jsx index f4d4a7c..a8f57c6 100644 --- a/src/components/museum/components/exhibition/ExhibitionDatePicker.jsx +++ b/src/components/museum/components/exhibition/ExhibitionDatePicker.jsx @@ -7,12 +7,17 @@ import styles from './exhibitionDatePicker.module.css'; export default function ExhibitionDatePicker() { const navigate = useNavigate(); const location = useLocation(); + + // 업로드 페이지에서 넘어온 draft/initialDates/returnTo + const draft = location.state?.draft; + const returnTo = location.state?.returnTo || -1; + const [currentDate, setCurrentDate] = useState(new Date()); const [selectedStartDate, setSelectedStartDate] = useState(null); const [selectedEndDate, setSelectedEndDate] = useState(null); const [isSelectingEndDate, setIsSelectingEndDate] = useState(false); - // URL 파라미터나 state에서 초기 날짜 가져오기 + // URL state에서 초기 날짜 가져오기 useEffect(() => { if (location.state?.initialDates) { const { startDate, endDate } = location.state.initialDates; @@ -21,22 +26,15 @@ export default function ExhibitionDatePicker() { } }, [location.state]); - // 현재 월의 첫 번째 날과 마지막 날 계산 + // 현재 월 계산 const firstDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); const lastDayOfMonth = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0); - - // 현재 월의 첫 번째 요일 (0: 일요일, 1: 월요일, ...) const firstDayOfWeek = firstDayOfMonth.getDay(); - - // 현재 월의 총 일수 const daysInMonth = lastDayOfMonth.getDate(); - // 이전 월로 이동 const goToPreviousMonth = () => { setCurrentDate(prev => new Date(prev.getFullYear(), prev.getMonth() - 1, 1)); }; - - // 다음 월로 이동 const goToNextMonth = () => { setCurrentDate(prev => new Date(prev.getFullYear(), prev.getMonth() + 1, 1)); }; @@ -44,22 +42,15 @@ export default function ExhibitionDatePicker() { // 날짜 선택 처리 const handleDateClick = (day) => { const clickedDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), day); - - // 등록일 기준 7일 뒤부터 선택 가능 const today = new Date(); - const minDate = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); - - if (clickedDate < minDate) { - return; // 선택 불가능한 날짜 - } + const minDate = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); // 7일 뒤부터 + if (clickedDate < minDate) return; if (!selectedStartDate || (selectedStartDate && selectedEndDate)) { - // 시작일 선택 또는 재선택 setSelectedStartDate(clickedDate); setSelectedEndDate(null); setIsSelectingEndDate(true); } else if (selectedStartDate && !selectedEndDate) { - // 종료일 선택 if (clickedDate >= selectedStartDate) { setSelectedEndDate(clickedDate); setIsSelectingEndDate(false); @@ -67,7 +58,6 @@ export default function ExhibitionDatePicker() { } }; - // 날짜가 선택 가능한지 확인 const isDateSelectable = (day) => { const date = new Date(currentDate.getFullYear(), currentDate.getMonth(), day); const today = new Date(); @@ -75,38 +65,38 @@ export default function ExhibitionDatePicker() { return date >= minDate; }; - // 날짜가 선택된 시작일인지 확인 const isStartDate = (day) => { if (!selectedStartDate) return false; - return day === selectedStartDate.getDate() && - currentDate.getMonth() === selectedStartDate.getMonth() && - currentDate.getFullYear() === selectedStartDate.getFullYear(); + return ( + day === selectedStartDate.getDate() && + currentDate.getMonth() === selectedStartDate.getMonth() && + currentDate.getFullYear() === selectedStartDate.getFullYear() + ); }; - // 날짜가 선택된 종료일인지 확인 const isEndDate = (day) => { if (!selectedEndDate) return false; - return day === selectedEndDate.getDate() && - currentDate.getMonth() === selectedEndDate.getMonth() && - currentDate.getFullYear() === selectedEndDate.getFullYear(); + return ( + day === selectedEndDate.getDate() && + currentDate.getMonth() === selectedEndDate.getMonth() && + currentDate.getFullYear() === selectedEndDate.getFullYear() + ); }; - // 날짜가 선택 범위 내에 있는지 확인 const isInRange = (day) => { if (!selectedStartDate || !selectedEndDate) return false; const date = new Date(currentDate.getFullYear(), currentDate.getMonth(), day); return date > selectedStartDate && date < selectedEndDate; }; - // 선택된 기간의 총 일수 계산 const getTotalDays = () => { if (!selectedStartDate || !selectedEndDate) return 0; const diffTime = selectedEndDate.getTime() - selectedStartDate.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return diffDays + 1; // 시작일과 종료일 포함 + // (선택된 시작/종료일이 동일하면 1일) }; - // 날짜 포맷팅 const formatDate = (date) => { if (!date) return ''; return ( @@ -127,28 +117,23 @@ export default function ExhibitionDatePicker() { ); }; - // 캘린더 그리드 생성 const generateCalendarDays = () => { const days = []; - - // 이전 월의 마지막 날들 (빈 칸 채우기) for (let i = 0; i < firstDayOfWeek; i++) { days.push(
); } - - // 현재 월의 날짜들 for (let day = 1; day <= daysInMonth; day++) { const isSelectable = isDateSelectable(day); const isStart = isStartDate(day); const isEnd = isEndDate(day); const inRange = isInRange(day); - + let dayClass = styles.day; if (!isSelectable) dayClass += ` ${styles.disabledDay}`; if (isStart) dayClass += ` ${styles.startDate}`; if (isEnd) dayClass += ` ${styles.endDate}`; if (inRange) dayClass += ` ${styles.inRange}`; - + days.push(
); } - return days; }; - // 뒤로가기 처리 const handleBack = () => { - navigate(-1); + // 뒤로 가되, draft를 유지하고 싶으면 state로 넘겨줌 + navigate(-1, { + state: { + draft + } + }); }; - // 완료 버튼 클릭 처리 const handleComplete = () => { if (selectedStartDate && selectedEndDate) { - // 전시 등록 페이지로 돌아가면서 선택된 날짜 전달 - navigate(-1, { - state: { - selectedDates: { - startDate: selectedStartDate, - endDate: selectedEndDate, - totalDays: getTotalDays() + // 로컬 시간 기준으로 YYYY-MM-DD 형식 변환 (UTC 시간대 문제 해결) + const formatDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + const startDateString = formatDate(selectedStartDate); + const endDateString = formatDate(selectedEndDate); + + // draft에 날짜 데이터를 YYYY-MM-DD 형식으로 추가 + const updatedDraft = { + ...draft, + startDate: startDateString, + endDate: endDateString, + totalDays: getTotalDays() + }; + + + + // 업로드 페이지로 돌아가면서 선택된 날짜 + draft 같이 전달 + // 업로드 페이지에서 location.state.selectedDates & .draft를 사용해 복원 + if (returnTo === 'exhibition-upload') { + navigate('/exhibition/upload', { + state: { + selectedDates: { + startDate: startDateString, + endDate: endDateString, + totalDays: getTotalDays() + }, + draft: updatedDraft + } + }); + } else { + // returnTo 정보가 없을 때는 업로드 경로를 직접 지정해도 됨 + navigate('/exhibition/upload', { + replace: true, + state: { + selectedDates: { + startDate: startDateString, + endDate: endDateString, + totalDays: getTotalDays() + }, + draft: updatedDraft } - } - }); + }); + } } }; - // 완료 버튼 활성화 여부 const isCompleteButtonActive = selectedStartDate && selectedEndDate; return ( @@ -244,9 +268,9 @@ export default function ExhibitionDatePicker() {
{/* 안내 메시지 */} -
+
전시 시작일은 등록일 기준 7일 뒤부터 설정 가능합니다 -
+
{/* 선택된 기간 정보 */}
diff --git a/src/components/museum/components/exhibition/ExhibitionList.jsx b/src/components/museum/components/exhibition/ExhibitionList.jsx index c194c03..da5f565 100644 --- a/src/components/museum/components/exhibition/ExhibitionList.jsx +++ b/src/components/museum/components/exhibition/ExhibitionList.jsx @@ -6,6 +6,7 @@ import chevronLeft from '@/assets/museum/chevron-left.png'; import arrowDown from '@/assets/museum/arrow-down.svg'; import xImage from '@/assets/museum/x.png'; import styles from './exhibitionList.module.css'; +import { getMyExhibitions } from '@/apis/museum/exhibition'; export default function ExhibitionList({ showAddButton = true, @@ -14,7 +15,7 @@ export default function ExhibitionList({ onBack, onExhibitionClick }) { - // 임시 전시 데이터 (추후 store로 대체) + // 전시 데이터 상태 const [exhibitions, setExhibitions] = useState([]); const [layoutMode, setLayoutMode] = useState('grid'); const [searchKeyword, setSearchKeyword] = useState(''); @@ -23,6 +24,22 @@ export default function ExhibitionList({ const [selectedExhibitions, setSelectedExhibitions] = useState(new Set()); const headerRef = useRef(); + // 전시 데이터 로드 + useEffect(() => { + const loadExhibitions = async () => { + try { + const response = await getMyExhibitions({ pageNum: 1, pageSize: 20, fillAll: true }); + if (response?.data?.data) { + setExhibitions(response.data.data.content || []); + } + } catch (error) { + console.error('전시 데이터 로드 실패:', error); + } + }; + + loadExhibitions(); + }, []); + // 필터된 전시 목록 const filteredExhibitions = exhibitions.filter(exhibition => exhibition.title.toLowerCase().includes(searchKeyword.toLowerCase()) || @@ -99,7 +116,7 @@ export default function ExhibitionList({ if (isCompletelyEmpty) { return ( -
+
{/* 헤더 */}
@@ -194,9 +211,9 @@ export default function ExhibitionList({ ) : ( // 정상적인 전시 목록
- {filteredExhibitions.map((exhibition, index) => ( -
- ( +
+ maxVisible; + const hasMoreArtworks = totalElements > maxVisible; const handleShowMore = () => { navigate('/artwork/my'); // 내 작품 페이지로 이동 @@ -21,26 +22,38 @@ export default function ArtworkSection({ artworks = [] }) {

내 작품

- _ {artworks.length}개 + _ {totalElements}개
- {visibleArtworks.map((artwork, index) => ( -
- ))} + {visibleArtworks.map((artwork, index) => { + const imageUrl = getImageUrl(artwork); + return ( +
+ ); + })} {hasMoreArtworks && (
{ + const imageUrl = getImageUrl(artworks[maxVisible]); + return imageUrl ? `url(${imageUrl})` : 'none'; + })(), + backgroundColor: (() => { + const imageUrl = getImageUrl(artworks[maxVisible]); + return imageUrl ? 'transparent' : '#f0f0f0'; + })() }} onClick={handleShowMore} > diff --git a/src/components/museum/components/museum/ExhibitionSection.jsx b/src/components/museum/components/museum/ExhibitionSection.jsx index d1175aa..525d953 100644 --- a/src/components/museum/components/museum/ExhibitionSection.jsx +++ b/src/components/museum/components/museum/ExhibitionSection.jsx @@ -1,10 +1,22 @@ import exhibitionStyles from './exhibitionSection.module.css'; import commonStyles from './common.module.css'; -export default function ExhibitionSection({ exhibitions = [] }) { +export default function ExhibitionSection({ exhibitions = [], totalElements = 0 }) { + console.log('ExhibitionSection 렌더링:', { exhibitions, totalElements, exhibitionsType: typeof exhibitions, totalElementsType: typeof totalElements }); + const maxVisible = 5; const visibleExhibitions = exhibitions.slice(0, maxVisible); - const hasMoreExhibitions = exhibitions.length > maxVisible; + const hasMoreExhibitions = totalElements > maxVisible; + + // 날짜 형식 변환 헬퍼 함수 + const formatDate = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + const year = date.getFullYear().toString().slice(2); // 24, 25 + const month = (date.getMonth() + 1).toString().padStart(2, '0'); // 12, 02 + const day = date.getDate().toString().padStart(2, '0'); // 05, 19 + return `${year}.${month}.${day}`; + }; const handleShowMore = () => { console.log('전체 전시 페이지로 이동'); @@ -15,23 +27,31 @@ export default function ExhibitionSection({ exhibitions = [] }) {

내 전시

- _ {exhibitions.length}개 + _ {totalElements}개
{visibleExhibitions.map((exhibition, index) => ( -
+
-

{exhibition.title}

-

{exhibition.date}

-
+

+ {exhibition.title || `전시 ${exhibition.exhibitionId || index + 1}`} +

+

+ {exhibition.startDate && exhibition.endDate ? + `${formatDate(exhibition.startDate)} - ${formatDate(exhibition.endDate)}` : + '등록 완료' + } +

+
))} @@ -40,7 +60,8 @@ export default function ExhibitionSection({ exhibitions = [] }) {
@@ -51,7 +72,7 @@ export default function ExhibitionSection({ exhibitions = [] }) {

 

-

 

+

 

)} diff --git a/src/components/museum/components/museum/InvitationSection.jsx b/src/components/museum/components/museum/InvitationSection.jsx new file mode 100644 index 0000000..6e0070c --- /dev/null +++ b/src/components/museum/components/museum/InvitationSection.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import styles from './invitationSection.module.css'; + +export default function InvitationSection({ hasInvitation, hasSharedLibraryRequest, invitationCount = 0 }) { + const navigate = useNavigate(); + + const handleInvitationClick = () => { + navigate('/exhibition/invitations'); + }; + + const handleSharedLibraryClick = () => { + navigate('/exhibition/shared-library-selection'); + }; + + return ( +
+ {/* 공동 전시 참여 요청 알림 */} + {hasInvitation && ( +
+ 공동 전시 참여 요청 ({invitationCount}개) +
+ )} + + {/* 작품 공유 라이브러리 등록 안내 */} + {hasSharedLibraryRequest && ( +
+ 작품을 공유 라이브러리에 등록해주세요 +
+ )} +
+ ); +} diff --git a/src/components/museum/components/museum/MuseumProfile.jsx b/src/components/museum/components/museum/MuseumProfile.jsx index 94e71cc..867ce5f 100644 --- a/src/components/museum/components/museum/MuseumProfile.jsx +++ b/src/components/museum/components/museum/MuseumProfile.jsx @@ -7,12 +7,12 @@ export default function MuseumProfile({ user }) {
-

{user.name}

-

{user.title}

+

{user.nickname || '사용자'}

+

크리에이터의 전시장

diff --git a/src/components/museum/components/museum/invitationSection.module.css b/src/components/museum/components/museum/invitationSection.module.css new file mode 100644 index 0000000..1780f32 --- /dev/null +++ b/src/components/museum/components/museum/invitationSection.module.css @@ -0,0 +1,54 @@ +.invitationSection { + margin-bottom: 20px; + width: 100%; + overflow: hidden; +} + +.invitationAlert { + height: 48px; + width: calc(100% - 70px); + max-width: 100%; + border: 2px solid #F37021; + background: #CEDDE4; + border-right: none; + display: flex; + align-items: center; + padding: 0 20px; + margin-top: 12px; + margin-bottom: 12px; + margin-left: 70px; + color: #000; + font-family: 'Pretendard', sans-serif; + font-size: 16px; + font-weight: 600; + line-height: 19px; + letter-spacing: -0.4px; + cursor: pointer; + box-sizing: border-box; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sharedLibraryAlert { + height: 48px; + width: calc(100% - 70px); + max-width: 100%; + border: 2px solid #F37021; + background: #CEDDE4; + border-right: none; + margin: 12px 0 0 70px; + display: flex; + align-items: center; + padding: 0 20px; + font-family: 'Pretendard', sans-serif; + font-size: 16px; + font-weight: 600; + line-height: 19px; + letter-spacing: -0.4px; + cursor: pointer; + box-sizing: border-box; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/src/components/museum/components/museum/museumProfile.module.css b/src/components/museum/components/museum/museumProfile.module.css index 522611b..fba0400 100644 --- a/src/components/museum/components/museum/museumProfile.module.css +++ b/src/components/museum/components/museum/museumProfile.module.css @@ -41,4 +41,5 @@ letter-spacing: -0.35px; line-height: 25px; margin: 0; + margin-top: 2px; } diff --git a/src/components/museum/components/museum/premiumBar.module.css b/src/components/museum/components/museum/premiumBar.module.css index a696ada..0d4b7b4 100644 --- a/src/components/museum/components/museum/premiumBar.module.css +++ b/src/components/museum/components/museum/premiumBar.module.css @@ -1,10 +1,11 @@ .premiumBar { height: 48px; - width: 360px; + width: fit-content; + min-width: 200px; background-color: #f37021; display: flex; align-items: center; - padding: 0 40px; + padding: 0 30px; border: 1px solid #000000; margin-left: -1px; margin-top: 20px; @@ -21,13 +22,14 @@ .subscriptionPrompt { height: 48px; - width: 360px; + width: fit-content; + min-width: 200px; background-color: #cfdde3; border: 1px solid #000000; border-left: none; display: flex; align-items: center; - padding: 0 40px; + padding: 0 30px; margin-left: -1px; margin-top: 20px; } diff --git a/src/components/museum/pages/ArtworkLibraryPage.jsx b/src/components/museum/pages/ArtworkLibraryPage.jsx index f270bea..07916b1 100644 --- a/src/components/museum/pages/ArtworkLibraryPage.jsx +++ b/src/components/museum/pages/ArtworkLibraryPage.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import ArtworkCard from '@museum/components/artwork/ArtworkCard'; -import useArtworkStore from '@museum/services/artworkStore'; +import { useInfinitePieces } from '@apis/museum/artwork'; import chevronLeft from '@/assets/museum/chevron-left.png'; import styles from './artworkLibraryPage.module.css'; @@ -10,16 +10,16 @@ export default function ArtworkLibraryPage() { const location = useLocation(); // 전시 등록 페이지에서 전달받은 정보 - const { fromExhibition, artworkIndex, isThumbnail, isChangeMode } = location.state || {}; + const { fromExhibition, artworkIndex, isThumbnail, isChangeMode, draft, returnTo } = location.state || {}; - // Zustand 스토어에서 상태 가져오기 + // API를 사용한 작품 목록 관리 const { - artworks, - getFilteredArtworks, - loadArtworks, - loadMoreArtworks, - hasMore - } = useArtworkStore(); + pieces: artworks, + loading, + hasMore, + loadMorePieces, + resetPieces + } = useInfinitePieces({ applicated: true, pageSize: 6 }); // 선택된 작품들 관리 const [selectedArtworks, setSelectedArtworks] = useState(new Set()); @@ -27,33 +27,40 @@ export default function ArtworkLibraryPage() { // 무한 스크롤을 위한 ref들 const observerRef = useRef(); - const loadingRef = useRef(); + const lastArtworkRef = useRef(); // 필터된 작품 목록 (검색어 없이 모든 작품) - const filteredArtworks = getFilteredArtworks(); + const filteredArtworks = artworks; - // 컴포넌트 마운트 시 작품 데이터 로드 (라이브러리 페이지에서는 6개) + // 컴포넌트 마운트 시 작품 데이터 초기화 useEffect(() => { - loadArtworks(true, 6); - }, [loadArtworks]); + resetPieces(); + }, [resetPieces]); // 무한 스크롤 구현 (IntersectionObserver 사용) const lastArtworkElementRef = useCallback((node) => { - if (artworks.isLoadingMore) return; + if (loading || !hasMore) return; if (observerRef.current) observerRef.current.disconnect(); observerRef.current = new IntersectionObserver(entries => { - if (entries[0].isIntersecting && hasMore()) { - loadMoreArtworks(); + if (entries[0].isIntersecting && hasMore) { + loadMorePieces(); } }); if (node) observerRef.current.observe(node); - }, [artworks.isLoadingMore, hasMore, loadMoreArtworks]); + }, [loading, hasMore, loadMorePieces]); const handleBack = () => { - if (fromExhibition) { - // 전시 등록 페이지로 돌아가기 + if (fromExhibition && returnTo === 'exhibition-upload') { + // 전시 등록 페이지로 돌아가면서 draft 데이터 전달 + navigate('/exhibition/upload', { + state: { + draft: draft + } + }); + } else if (fromExhibition) { + // 전시 등록 페이지로 돌아가기 (기존 방식) navigate('/exhibition/upload', { state: { returnFromLibrary: true, @@ -67,16 +74,25 @@ export default function ArtworkLibraryPage() { }; const handleArtworkSelection = (artwork) => { + const artworkId = artwork.pieceId || artwork.id; // pieceId 우선, 없으면 id 사용 + + console.log('작품 선택:', { + artwork, + artworkId, + currentSelected: Array.from(selectedArtworks), + isChangeMode + }); + if (isChangeMode) { // 변경 모드: 한 장만 선택 가능 - setSelectedArtworks(new Set([artwork.id])); + setSelectedArtworks(new Set([artworkId])); } else { // 새로 추가 모드: 여러 장 선택 가능 const newSelected = new Set(selectedArtworks); - if (newSelected.has(artwork.id)) { - newSelected.delete(artwork.id); + if (newSelected.has(artworkId)) { + newSelected.delete(artworkId); } else { - newSelected.add(artwork.id); + newSelected.add(artworkId); } setSelectedArtworks(newSelected); } @@ -87,22 +103,47 @@ export default function ArtworkLibraryPage() { // 선택된 작품들 가져오기 const selectedArtworkList = filteredArtworks.filter(artwork => - selectedArtworks.has(artwork.id) + selectedArtworks.has(artwork.pieceId || artwork.id) ); if (fromExhibition && selectedArtworkList.length > 0) { - // 전시 등록 페이지로 돌아가면서 선택된 작품 정보 전달 - navigate('/exhibition/upload', { - state: { - returnFromLibrary: true, - selectedArtworks: selectedArtworkList, // 선택된 모든 작품 배열 - artworkIndex: artworkIndex, - isThumbnail: isThumbnail - } - }); + if (returnTo === 'exhibition-upload') { + // draft 데이터와 함께 선택된 작품 정보 전달 + navigate('/exhibition/upload', { + state: { + draft: draft, + returnFromLibrary: true, + selectedArtworks: selectedArtworkList, // 선택된 모든 작품 배열 + artworkIndex: artworkIndex, + isThumbnail: isThumbnail + } + }); + } else { + // 기존 방식 (하위 호환성) + navigate('/exhibition/upload', { + state: { + returnFromLibrary: true, + selectedArtworks: selectedArtworkList, // 선택된 모든 작품 배열 + artworkIndex: artworkIndex, + isThumbnail: isThumbnail + } + }); + } } }; + const handleArtworkClick = (artwork) => { + // 작품 클릭 시에는 아무 동작하지 않음 (체크박스만 동작) + // 필요시 작품 상세 페이지로 이동하는 로직 추가 가능 + console.log('작품 클릭:', artwork); + }; + + // 작품 삭제 완료 시 작품 목록 새로고침 + const handleArtworkDeleted = (deletedIds) => { + console.log('삭제된 작품 ID들:', deletedIds); + // 작품 목록을 새로고침 + resetPieces(); + }; return ( @@ -128,26 +169,27 @@ export default function ArtworkLibraryPage() { // 마지막 요소에 ref 추가 (무한 스크롤용) const isLast = index === filteredArtworks.length - 1; return ( -
- handleArtworkSelection(artwork)} - showDate={true} - showStatus={false} - showDescription={true} - isEditMode={true} - isSelected={selectedArtworks.has(artwork.id)} - onSelect={handleArtworkSelection} - isLibraryMode={true} - checkboxSize="large" - /> +
+
); })} {/* 더 로딩 중 표시 */} - {artworks.isLoadingMore && ( + {loading && (

더 많은 작품을 불러오는 중...

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() {
메인 이미지
- +
디테일 컷{index + 1}
- + -
); diff --git a/src/components/museum/pages/ExhibitionInvitationPage.jsx b/src/components/museum/pages/ExhibitionInvitationPage.jsx new file mode 100644 index 0000000..13c5dd4 --- /dev/null +++ b/src/components/museum/pages/ExhibitionInvitationPage.jsx @@ -0,0 +1,134 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import chevronLeft from '@/assets/museum/chevron-left.png'; +import checkImage from '@/assets/museum/check.png'; +import styles from './exhibitionInvitationPage.module.css'; + +export default function ExhibitionInvitationPage() { + const navigate = useNavigate(); + // 상태 관리 + const [invitations, setInvitations] = useState([]); + const [selectedInvitations, setSelectedInvitations] = useState([]); + + // 더미 초대 데이터 (실제로는 API에서 가져올 예정) + const dummyInvitations = [ + { + id: 1, + exhibitionTitle: '2025 성북구 작가 단체전', + exhibitionImage: null, + status: 'pending' // pending, accepted, declined + }, + { + id: 2, + exhibitionTitle: '2025 성북구 작가 단체전', + exhibitionImage: null, + status: 'pending' + }, + { + id: 3, + exhibitionTitle: '2025 강남구 작가 단체전', + exhibitionImage: null, + status: 'pending' + } + ]; + + useEffect(() => { + // 초대 목록 가져오기 (실제로는 API 호출) + setInvitations(dummyInvitations); + }, []); + + const handleBack = () => { + navigate('/museum'); + }; + + const handleInvitationSelect = (invitationId) => { + const isSelected = selectedInvitations.includes(invitationId); + + if (isSelected) { + setSelectedInvitations(prev => prev.filter(id => id !== invitationId)); + } else { + setSelectedInvitations(prev => [...prev, invitationId]); + } + }; + + const handleAcceptInvitations = () => { + if (selectedInvitations.length > 0) { + // 선택된 초대들을 수락 처리 + console.log('수락할 초대:', selectedInvitations); + + // TODO: API 호출로 초대 수락 처리 + + // userStore 상태 업데이트 + updateInvitation({ + hasInvitation: false, // 공동 전시 참여 요청 알림 숨김 + hasSharedLibraryRequest: true, // 작품 공유 라이브러리 등록 안내 표시 + invitationCount: 0 // 요청 개수 0으로 설정 + }); + + // 내 전시장 페이지로 돌아가기 + navigate('/museum'); + } else { + alert('수락할 초대를 선택해주세요.'); + } + }; + + const isInvitationSelected = (invitationId) => { + return selectedInvitations.includes(invitationId); + }; + + return ( +
+ {/* 헤더 */} +
+
+ +

참여 요청 받은 전시

+
+
+ + {/* 초대 목록 */} +
+ {invitations.map((invitation) => ( +
+
+
+ {invitation.exhibitionImage ? ( + 전시 이미지 + ) : ( +
+ )} +
+
+ {invitation.exhibitionTitle} +
+
+ + {/* 커스텀 체크박스 - check.png 이미지 사용 */} +
handleInvitationSelect(invitation.id)} + > + {isInvitationSelected(invitation.id) && ( + 체크됨 + )} +
+
+ ))} +
+ + {/* 수락하기 버튼 */} + +
+ ); +} diff --git a/src/components/museum/pages/ExhibitionParticipantPage.jsx b/src/components/museum/pages/ExhibitionParticipantPage.jsx index 1524470..79ad2f5 100644 --- a/src/components/museum/pages/ExhibitionParticipantPage.jsx +++ b/src/components/museum/pages/ExhibitionParticipantPage.jsx @@ -1,93 +1,108 @@ import { useState, useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import useUserStore from '@/stores/userStore'; import chevronLeft from '@/assets/museum/chevron-left.png'; import searchIcon from '@/assets/footer/search.svg'; +import { getUserProfilesByCode } from '@/apis/user/user.js'; import styles from './exhibitionParticipantPage.module.css'; export default function ExhibitionParticipantPage() { const navigate = useNavigate(); const location = useLocation(); - const { user } = useUserStore(); // 상태 관리 const [searchQuery, setSearchQuery] = useState(''); - const [selectedParticipants, setSelectedParticipants] = useState([]); const [searchResults, setSearchResults] = useState([]); - const [showNotification, setShowNotification] = useState(false); + const [selectedParticipants, setSelectedParticipants] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); - // URL state에서 전시 정보 받아오기 - useEffect(() => { - if (location.state?.exhibitionData) { - console.log('전시 정보:', location.state.exhibitionData); - } - }, [location.state]); + // URL state에서 draft 정보 받아오기 + const draft = location.state?.draft || {}; + const exhibitionData = draft.exhibitionData || {}; - // 전시 정보가 없으면 전시 등록 페이지로 리다이렉트 - useEffect(() => { - if (!location.state?.exhibitionData) { - navigate('/exhibition/upload'); + // 사용자 검색 함수 + const searchUsers = async (query) => { + if (!query.trim()) { + setSearchResults([]); + setIsSearching(false); + return; } - }, [location.state, navigate]); - const handleBack = () => { - navigate('/exhibition/upload', { - state: { - exhibitionData: location.state?.exhibitionData + setIsSearching(true); + try { + // 검색어를 그대로 사용 + const keyword = query.trim(); + + const response = await getUserProfilesByCode(keyword); + + if (response && response.data && Array.isArray(response.data)) { + setSearchResults(response.data); + } else { + setSearchResults([]); } - }); - }; - - const handleSearchChange = (e) => { - const query = e.target.value; - setSearchQuery(query); - - // 검색어가 있을 때만 검색 결과 표시 - if (query.trim()) { - // 실제로는 API 호출을 통해 사용자 검색 - // 여기서는 더미 데이터 사용 - const dummyResults = [ - { id: 1, name: '김땡땡', username: 'kimdangdeng', profileImage: null }, - { id: 2, name: '정땡땡', username: 'simonkim', profileImage: null }, - { id: 3, name: 'kimman', username: 'kimchiman', profileImage: null } - ].filter(user => - user.name.toLowerCase().includes(query.toLowerCase()) || - user.username.toLowerCase().includes(query.toLowerCase()) - ); - setSearchResults(dummyResults); - } else { + } catch (error) { + console.error('사용자 검색 실패:', error); setSearchResults([]); + } finally { + setIsSearching(false); } }; - const handleParticipantToggle = (participant) => { - setSelectedParticipants(prev => { - const isSelected = prev.find(p => p.id === participant.id); - if (isSelected) { - return prev.filter(p => p.id !== participant.id); + // 검색어 변경 시 디바운스 처리 + useEffect(() => { + const timer = setTimeout(() => { + if (searchQuery.trim()) { + searchUsers(searchQuery); } else { - return [...prev, participant]; + setSearchResults([]); + setIsSearching(false); } - }); + }, 500); // 500ms 디바운스 + + return () => clearTimeout(timer); + }, [searchQuery]); + + const handleBack = () => { + navigate('/museum'); }; - const handleCompleteRegistration = () => { - if (selectedParticipants.length === 0) { - alert('참여자를 선택해주세요.'); - return; + const handleSearchChange = (e) => { + setSearchQuery(e.target.value); + }; + + const handleUserSelect = (user) => { + const userId = user.userId || user.id; + const isSelected = selectedParticipants.some(p => (p.userId || p.id) === userId); + + if (isSelected) { + // 이미 선택된 사용자라면 제거 + setSelectedParticipants(prev => prev.filter(p => (p.userId || p.id) !== userId)); + } else { + // 새로운 사용자라면 추가 + setSelectedParticipants(prev => [...prev, user]); } + }; - // 전시 등록 페이지로 돌아가면서 참여자 정보 전달 - navigate('/exhibition/upload', { - state: { - exhibitionData: location.state?.exhibitionData, - participants: selectedParticipants - } - }); + const handleComplete = () => { + // 성공 모달 표시 + setShowSuccessModal(true); + + // 2초 후 화면 전환 + setTimeout(() => { + // 전시 업로드 페이지로 돌아가면서 전체 draft 정보 전달 + navigate('/exhibition/upload', { + state: { + draft: { + ...draft, // 기존 draft 데이터 모두 포함 + participants: selectedParticipants // 참여자 정보 추가/업데이트 + } + } + }); + }, 2000); }; - const isParticipantSelected = (participantId) => { - return selectedParticipants.find(p => p.id === participantId); + const isUserSelected = (userId) => { + return selectedParticipants.some(p => (p.userId || p.id) === userId); }; return ( @@ -96,83 +111,111 @@ export default function ExhibitionParticipantPage() {
-

전시 참여자 등록하기

+

+ {selectedParticipants.length > 0 ? '전시 참여자 등록됨' : '전시 참여자 등록하기'} +

- {/* 검색 입력창 */} + {/* 검색창 */}
-
+
- search +
+ 검색 +
- {/* 검색 결과가 있을 때만 표시 */} + {/* 안내 메시지 */} {searchResults.length > 0 && ( - <> -

- {selectedParticipants.length > 0 - ? "프로필을 누르면 등록이 취소됩니다" - : "프로필을 누르면 등록이 완료됩니다" - } -

- - {/* 참여자 목록 */} -
- {searchResults.map((participant) => ( -
handleParticipantToggle(participant)} - > +

+ {selectedParticipants.length === 0 + ? '프로필을 누르면 등록이 완료됩니다' + : `프로필을 누르면 등록이 취소됩니다 (${selectedParticipants.length})` + } +

+ )} + + {/* 검색 결과 목록 */} + {console.log('렌더링 조건 확인:', { isSearching, searchResultsLength: searchResults.length, searchResults })} + {searchResults.length > 0 && ( +
+ {searchResults.map((user) => ( +
handleUserSelect(user)} + > +
- {participant.profileImage ? ( - {participant.name} + {user.profileImageUrl ? ( + 프로필 ) : ( -
+
)}
-
-

- {participant.name} @{participant.username} -

+
+ {user.nickname || user.displayName} + @{user.code || user.username}
- ))} -
- +
+ ))} +
)} - {/* 하단 버튼 */} + {/* 선택된 참여자 목록 */} + {selectedParticipants.length > 0 && ( +
+

선택된 참여자

+ {selectedParticipants.map((user) => ( +
handleUserSelect(user)} + > +
+
+ {user.profileImageUrl ? ( + 프로필 + ) : ( +
+ )} +
+
+ {user.nickname || user.displayName} + @{user.code || user.username} +
+
+
+ ))} +
+ )} + + {/* 등록 완료 버튼 */} - {/* 알림 배너 */} - {showNotification && ( -
- 참여자가 등록 요청이 전송되었습니다 + {/* 성공 모달 */} + {showSuccessModal && ( +
+
+ 참여자 등록 요청이 전송되었습니다 +
)}
diff --git a/src/components/museum/pages/ExhibitionUploadPage.jsx b/src/components/museum/pages/ExhibitionUploadPage.jsx index f0d8a0a..f710b07 100644 --- a/src/components/museum/pages/ExhibitionUploadPage.jsx +++ b/src/components/museum/pages/ExhibitionUploadPage.jsx @@ -1,27 +1,16 @@ import { useState, useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; -import useUserStore from '@/stores/userStore'; -import useExhibitionPhotoStore from '@museum/services/exhibitionPhotoStore'; import chevronLeft from '@/assets/museum/chevron-left.png'; import cameraIcon from '@/assets/user/camera.png'; import plusCircleIcon from '@/assets/museum/plus-circle.png'; import ExhibitionArtworkModal from '@museum/components/exhibition/ExhibitionArtworkModal'; +import { getUserContact, getCurrentUser } from '@/apis/user/user.js'; +import { createExhibition } from '@/apis/museum/exhibition.js'; import styles from './exhibitionUploadPage.module.css'; export default function ExhibitionUploadPage() { const navigate = useNavigate(); const location = useLocation(); - const { user, contactInfo } = useUserStore(); - - // Zustand store 사용 - const { - thumbnail, - artworks, - setThumbnail, - addArtwork, - updateArtwork, - removeArtwork: removeArtworkFromStore - } = useExhibitionPhotoStore(); // 로컬 상태로 관리 (전시 정보) const [exhibitionData, setExhibitionData] = useState({ @@ -32,25 +21,174 @@ export default function ExhibitionUploadPage() { totalDays: 0 }); - // 모달 상태 추가 + + // 모달 상태 const [isArtworkModalOpen, setIsArtworkModalOpen] = useState(false); const [currentArtworkIndex, setCurrentArtworkIndex] = useState(0); const [isChangeMode, setIsChangeMode] = useState(false); + + // 썸네일 및 작품 상태 + const [thumbnail, setThumbnail] = useState(null); + const [artworks, setArtworks] = useState([]); // 작품 객체 배열 (ID와 imageUrl 포함) + + // 연락 정보 상태 + const [contactInfo, setContactInfo] = useState({ + isRegistered: false + }); + + // 전시 등록 관련 상태 + const [isSubmitting, setIsSubmitting] = useState(false); + const [offlineLocation, setOfflineLocation] = useState({ + address: '', + addressName: '', + offlineDescription: '' + }); + const [participants, setParticipants] = useState([]); - // URL state에서 선택된 날짜 받아오기 + // 기간 설정 완료 상태 확인 함수 + const isDateRangeSet = () => { + const { startDate, endDate } = exhibitionData; + + // Date 객체인 경우 + if (startDate instanceof Date && endDate instanceof Date) { + return !isNaN(startDate.getTime()) && !isNaN(endDate.getTime()); + } + + // 문자열인 경우 (YYYY-MM-DD 형식) + if (typeof startDate === 'string' && typeof endDate === 'string') { + const startPattern = /^\d{4}-\d{2}-\d{2}$/; + const endPattern = /^\d{4}-\d{2}-\d{2}$/; + return startPattern.test(startDate) && endPattern.test(endDate); + } + + return false; + }; + + // 오프라인 장소 등록 완료 상태 확인 함수 + const isOfflineLocationSet = () => { + const { address, addressName } = offlineLocation; + return address && address.trim().length > 0 && addressName && addressName.trim().length > 0; + }; + + // 현재 입력 상태를 하나로 묶어 라우팅 state로 넘길 draft + const buildDraft = () => { + // artworks에서 pieceId만 추출하여 리스트 구성 + const pieceIds = artworks.map(artwork => artwork.pieceId || artwork.id).filter(Boolean); + + const draft = { + exhibitionData, + thumbnail, + artworks, + pieceIds, // pieceId 리스트 추가 + offlineLocation, + participants, + contactRegistered: contactInfo.isRegistered, + // API 요청 형식에 맞춰 날짜 데이터 저장 (이미 YYYY-MM-DD 형식) + startDate: exhibitionData.startDate || null, + endDate: exhibitionData.endDate || null, + totalDays: exhibitionData.totalDays + }; + + return draft; + }; + + // 기간 설정 페이지에서 돌아왔을 때 데이터 복원 (통합) useEffect(() => { - if (location.state?.selectedDates) { - const { startDate, endDate, totalDays } = location.state.selectedDates; - setExhibitionData(prev => ({ - ...prev, - startDate: new Date(startDate), - endDate: new Date(endDate), - totalDays - })); + // location.state가 실제로 유효한 데이터를 가지고 있을 때만 처리 + if (location.state && (location.state.selectedDates || location.state.draft)) { + const { selectedDates, draft } = location.state; + + setExhibitionData(prev => { + let newData = { ...prev }; + + // 1단계: draft 데이터 처리 (기본 데이터 먼저) + if (draft) { + // draft.exhibitionData에서 날짜 정보는 제외하고 기본 데이터만 설정 + if (draft.exhibitionData) { + // 날짜 관련 필드를 명시적으로 제외 + const safeExhibitionData = { ...draft.exhibitionData }; + delete safeExhibitionData.startDate; + delete safeExhibitionData.endDate; + delete safeExhibitionData.totalDays; + + newData = { + ...newData, + ...safeExhibitionData + }; + } + } + + // 2단계: 기간 설정 데이터 처리 (최우선 - 나중에 덮어씀) + if (selectedDates) { + const { startDate, endDate, totalDays } = selectedDates; + + newData = { + ...newData, + startDate: startDate, // 이미 YYYY-MM-DD 문자열 + endDate: endDate, // 이미 YYYY-MM-DD 문자열 + totalDays + }; + } + + // 3단계: draft에서 날짜 데이터 복원 (selectedDates가 없을 때만) + if (!selectedDates && draft) { + // draft에서 직접 날짜 데이터 복원 + if (draft.startDate && draft.endDate) { + newData = { + ...newData, + startDate: draft.startDate, + endDate: draft.endDate, + totalDays: draft.totalDays || 0 + }; + } + + // draft.exhibitionData에서도 날짜 데이터 확인 및 복원 + if (draft.exhibitionData && draft.exhibitionData.startDate && draft.exhibitionData.endDate) { + newData = { + ...newData, + startDate: draft.exhibitionData.startDate, + endDate: draft.exhibitionData.endDate, + totalDays: draft.exhibitionData.totalDays || 0 + }; + } + } + + return newData; + }); + + // 기타 상태 업데이트 (exhibitionData와 독립적) + if (draft) { + if (draft.thumbnail) setThumbnail(draft.thumbnail); + if (draft.artworks) setArtworks(draft.artworks); + if (draft.offlineLocation) { + setOfflineLocation(draft.offlineLocation); + } + if (draft.participants && Array.isArray(draft.participants)) { + setParticipants(draft.participants); + } + if (typeof draft.contactRegistered === 'boolean') { + setContactInfo(prev => ({ ...prev, isRegistered: draft.contactRegistered })); + } + } } }, [location.state]); - // URL state에서 참여자 정보 받아오기 + // ✅ URL state에 draft가 있으면 전부 복원 (통합된 useEffect에서 처리하므로 주석 처리) + // useEffect(() => { + // if (location.state?.draft) { + // const d = location.state.draft; + // if (d.exhibitionData) setExhibitionData(prev => ({ ...prev, ...d.exhibitionData })); + // if (d.thumbnail) setThumbnail(d.thumbnail); + // if (d.artworks) setArtworks(d.artworks); + // if (d.offlineLocation) setOfflineLocation(d.offlineLocation); + // if (d.participants) setParticipants(d.participants); + // if (typeof d.contactRegistered === 'boolean') { + // setContactInfo(prev => ({ ...prev, isRegistered: d.contactRegistered })); + // } + // } + // }, [location.state]); + + // URL state에서 참여자 정보 받아오기 (필요 시 추가 처리) useEffect(() => { if (location.state?.participants) { console.log('참여자 정보:', location.state.participants); @@ -58,6 +196,280 @@ export default function ExhibitionUploadPage() { } }, [location.state]); + // 사용자 연락 정보 확인 + useEffect(() => { + const checkUserContact = async () => { + try { + const userResponse = await getCurrentUser(); + if (userResponse && userResponse.data && userResponse.data.userId) { + const userId = userResponse.data.userId; + const contactResponse = await getUserContact(userId); + + if (contactResponse && contactResponse.data) { + const contactData = contactResponse.data; + if (contactData.email || contactData.instagram) { + setContactInfo(prev => ({ + ...prev, + isRegistered: true + })); + } + } + } + } catch (error) { + console.error('연락 정보 확인 실패:', error); + } + }; + checkUserContact(); + }, []); + + // 연락 정보 등록 페이지에서 돌아왔을 때 전시 정보 복원 + useEffect(() => { + if (location.state?.contactUpdated && location.state?.draft) { + const { draft } = location.state; + + // draft에서 모든 상태 복원 + if (draft.exhibitionData) { + setExhibitionData(prev => ({ + ...prev, + ...draft.exhibitionData + })); + } + if (draft.thumbnail) setThumbnail(draft.thumbnail); + if (draft.artworks) setArtworks(draft.artworks); + if (draft.offlineLocation) setOfflineLocation(draft.offlineLocation); + if (draft.participants) setParticipants(draft.participants); + + setContactInfo(prev => ({ + ...prev, + isRegistered: true + })); + } else if (location.state?.contactUpdated && location.state?.exhibitionData) { + const { exhibitionData: savedExhibitionData, thumbnail: savedThumbnail, artworks: savedArtworks } = location.state; + + console.log('연락 정보 등록 후 복원 데이터:', { + savedExhibitionData, + savedThumbnail, + savedArtworks + }); + + setExhibitionData(prev => ({ + ...prev, + ...savedExhibitionData + })); + + if (savedThumbnail) setThumbnail(savedThumbnail); + if (savedArtworks) setArtworks(savedArtworks); + + setContactInfo(prev => ({ + ...prev, + isRegistered: true + })); + } + }, [location.state]); + + // 오프라인 장소 등록 페이지에서 돌아왔을 때 정보 복원 + useEffect(() => { + if (location.state?.offlineLocationData && location.state?.returnTo === 'exhibition-upload') { + const { address, addressName, offlineDescription } = location.state.offlineLocationData; + setOfflineLocation({ + address: address || '', + addressName: addressName || '', + offlineDescription: offlineDescription || '' + }); + } + }, [location.state]); + + // 전시 참여자 등록 페이지에서 돌아왔을 때 정보 복원 + useEffect(() => { + if (location.state?.participantsData && location.state?.returnTo === 'exhibition-upload') { + setParticipants(location.state.participantsData); + } + }, [location.state]); + + // 작품 라이브러리에서 돌아왔을 때 선택된 작품들 처리 + useEffect(() => { + if (location.state?.returnFromLibrary && location.state?.selectedArtworks) { + const { selectedArtworks, artworkIndex, isThumbnail, draft } = location.state; + + console.log('라이브러리에서 선택된 작품들:', selectedArtworks); + console.log('복원할 draft:', draft); + + // 먼저 draft에서 상태 복원 + if (draft) { + if (draft.exhibitionData) { + setExhibitionData(prev => ({ + ...prev, + ...draft.exhibitionData + })); + } + if (draft.thumbnail) setThumbnail(draft.thumbnail); + if (draft.artworks) setArtworks(draft.artworks); + if (draft.offlineLocation) setOfflineLocation(draft.offlineLocation); + if (draft.participants) setParticipants(draft.participants); + if (typeof draft.contactRegistered === 'boolean') { + setContactInfo(prev => ({ ...prev, isRegistered: draft.contactRegistered })); + } + } + + // 그 다음 선택된 작품 처리 + if (isThumbnail) { + // 썸네일로 선택된 경우 + if (selectedArtworks.length > 0) { + setThumbnail(selectedArtworks[0]); + } + } else { + // 전시 작품으로 선택된 경우 + if (artworkIndex !== undefined) { + // 특정 인덱스에 작품 추가/교체 + if (artworkIndex >= 0) { + if (artworkIndex < artworks.length) { + // 기존 작품 교체 + updateArtwork(artworkIndex, selectedArtworks[0]); + } else { + // 새 작품 추가 + addArtwork(selectedArtworks[0]); + } + } else { + // 새 작품 추가 + addArtwork(selectedArtworks[0]); + } + } else { + // 여러 작품 추가 + selectedArtworks.forEach(artwork => { + addArtwork(artwork); + }); + } + } + } + }, [location.state]); + + // 새 작품 등록 페이지에서 돌아왔을 때 처리 + useEffect(() => { + if (location.state?.returnFromArtworkUpload && location.state?.draft) { + const { draft, newArtwork, isThumbnail } = location.state; + + console.log('새 작품 등록 후 복원 데이터:', { draft, newArtwork, isThumbnail }); + + // draft에서 상태 복원 + if (draft) { + if (draft.exhibitionData) { + setExhibitionData(prev => ({ + ...prev, + ...draft.exhibitionData + })); + } + if (draft.thumbnail) setThumbnail(draft.thumbnail); + if (draft.artworks) setArtworks(draft.artworks); + if (draft.offlineLocation) setOfflineLocation(draft.offlineLocation); + if (draft.participants) setParticipants(draft.participants); + if (typeof draft.contactRegistered === 'boolean') { + setContactInfo(prev => ({ ...prev, isRegistered: draft.contactRegistered })); + } + } + + // 새로 등록된 작품 처리 + if (newArtwork) { + if (isThumbnail) { + setThumbnail(newArtwork); + } else { + addArtwork(newArtwork); + } + } + } + }, [location.state]); + + // Draft 초기화 함수 + const resetDraft = () => { + setExhibitionData({ + title: '', + description: '', + startDate: '', // YYYY-MM-DD 형식 문자열로 초기화 + endDate: '', // YYYY-MM-DD 형식 문자열로 초기화 + totalDays: 0 + }); + setThumbnail(null); + setArtworks([]); + setOfflineLocation({ + address: '', + addressName: '', + offlineDescription: '' + }); + setParticipants([]); + setContactInfo({ + isRegistered: false + }); + + }; + + // 전시 등록 처리 + const handleSubmitExhibition = async () => { + // 필수 입력 검증 + if (!exhibitionData.title || !exhibitionData.title.trim()) { + alert('전시명을 입력해주세요.'); + return; + } + if (!exhibitionData.description || !exhibitionData.description.trim()) { + alert('전시 소개를 입력해주세요.'); + return; + } + if (!exhibitionData.startDate || !exhibitionData.endDate) { + alert('전시 기간을 설정해주세요.'); + return; + } + if (artworks.length < 3) { + alert('작품을 3개 이상 등록해주세요.'); + return; + } + if (!contactInfo.isRegistered) { + alert('연락 정보를 등록해주세요.'); + return; + } + + setIsSubmitting(true); + try { + // buildDraft()에서 pieceIds 사용 + const draft = buildDraft(); + const pieceIdList = draft.pieceIds || []; + const participantIdList = participants.map(p => p.userId || p.id); + + // draft에서 날짜 데이터 사용 (이미 YYYY-MM-DD 형식) + const startDate = exhibitionData.startDate; + const endDate = exhibitionData.endDate; + + // thumbnailImageUrl 설정 (썸네일 작품의 이미지 URL) + let thumbnailImageUrl = null; + if (thumbnail) { + thumbnailImageUrl = thumbnail.imageUrl || thumbnail.url || thumbnail; + } + + const exhibitionPayload = { + pieceIdList, + endDate, + participantIdList, + startDate, + address: offlineLocation.address || '주소 미입력', + title: exhibitionData.title.trim(), + offlineDescription: offlineLocation.offlineDescription || '오프라인 전시 설명 미입력', + description: exhibitionData.description.trim(), + addressName: offlineLocation.addressName || '장소명 미입력', + thumbnailImageUrl // 썸네일 이미지 URL 추가 + }; + + const response = await createExhibition(exhibitionPayload); + + // Draft 초기화 + resetDraft(); + + alert('전시가 성공적으로 등록되었습니다!'); + navigate('/museum'); + } catch (error) { + console.error('전시 등록 실패:', error); + alert('전시 등록에 실패했습니다. 다시 시도해주세요.'); + } finally { + setIsSubmitting(false); + } + }; + const handleBack = () => { navigate('/museum'); }; @@ -72,9 +484,7 @@ export default function ExhibitionUploadPage() { const handleThumbnailChange = (e) => { const file = e.target.files[0]; - if (file) { - setThumbnail(file); - } + if (file) setThumbnail(file); }; // 전시 작품 등록 모달 열기 (새로 추가) @@ -94,97 +504,85 @@ export default function ExhibitionUploadPage() { // 새 작품 등록 처리 const handleNewArtwork = (file) => { if (currentArtworkIndex === -1) { - // 썸네일인 경우 setThumbnail(file); } else { - // 작품인 경우 if (isChangeMode) { - // 변경 모드: 해당 인덱스의 작품만 수정 updateArtwork(currentArtworkIndex, file); } else { - // 새로 추가 모드: 작품 추가 addArtwork(file); } } }; - // 작품 라이브러리에서 가져오기 처리 - const handleLoadFromLibrary = () => { - // 작품 라이브러리 페이지로 이동 - navigate('/artwork/library', { - state: { - fromExhibition: true, - currentArtworkIndex, - isChangeMode - } + + + + + // 작품 추가/수정/삭제 (객체 기반) + const addArtwork = (artwork) => setArtworks(prev => [...prev, artwork]); + const updateArtwork = (index, artwork) => { + setArtworks(prev => { + const next = [...prev]; + next[index] = artwork; + return next; }); }; - - // 작품 제거 처리 const removeArtwork = (index) => { - removeArtworkFromStore(index); + setArtworks(prev => prev.filter((_, i) => i !== index)); }; - // 기간 설정 페이지로 이동 + // 기간 설정 페이지로 이동 (초안 동봉!) const openDatePicker = () => { navigate('/exhibition/date-picker', { state: { initialDates: { startDate: exhibitionData.startDate, endDate: exhibitionData.endDate - } + }, + draft: buildDraft(), // ✅ 현재까지 입력한 값 모두 + returnTo: 'exhibition-upload' } }); }; - // 전시 작품 슬라이드 렌더링 + // 전시 작품 슬라이드 렌더링 (객체 기반) const renderArtworkSlides = () => { const slides = []; - - // 기존 작품들 렌더링 artworks.forEach((artwork, index) => { slides.push(
- {artwork ? ( -
- {`작품 - - +
+ {`작품 { + e.target.style.display = 'none'; + e.target.nextSibling.style.display = 'block'; + }} + /> +
+ 이미지를 불러올 수 없습니다
- ) : ( -
openArtworkModal(index)} - style={{ cursor: 'pointer' }} +
- )} + 변경 + + +
); }); - - // 새 작품 추가 버튼 (최대 10개) + if (artworks.length < 10) { slides.push(
@@ -204,7 +602,6 @@ export default function ExhibitionUploadPage() {
); } - return slides; }; @@ -212,148 +609,150 @@ export default function ExhibitionUploadPage() {
{/* 헤더 */}
- -

전시 등록하기

+
+ +

전시 등록하기

+
-
-
- {/* 전시 썸네일 및 작품 등록 */} -
-

전시 작품 등록

-
- {/* 전시 썸네일 */} -
-
- {thumbnail ? ( -
- 전시 썸네일 - -
- ) : ( -
openArtworkModal(-1)} // -1은 썸네일을 의미 - style={{ cursor: 'pointer' }} +
+ {/* 전시 썸네일 및 작품 등록 */} +
+

전시 작품 등록

+
+ {/* 전시 썸네일 */} +
+
+ {thumbnail ? ( +
+ 전시 썸네일 +
- )} -
+ 변경 + +
+ ) : ( +
openArtworkModal(-1)} // -1은 썸네일 + style={{ cursor: 'pointer' }} + > + camera +

+ 전시 썸네일을 등록해주세요
+ (필수) +

+
+ )}
- - {/* 전시 작품 슬라이드 (동적으로 생성) */} - {renderArtworkSlides()}
-
- {/* 전시명 입력 */} -
- + {/* 전시 작품 슬라이드 */} + {renderArtworkSlides()}
+
- {/* 전시 소개 입력 */} -
-