From e7d5e06b766b6e27399bda3526496124e197ad27 Mon Sep 17 00:00:00 2001 From: Heeyeun Ko Date: Fri, 15 Nov 2024 13:06:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B6=84=EC=84=9D=20=EC=A4=91=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20=EC=8A=A4=ED=94=BC=EB=84=88=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 11 + package.json | 1 + src/components/LoadingSpinner.tsx | 42 ++++ src/pages/Camera/CameraPage.tsx | 171 +++++++++------ src/pages/Start/SignupPage.tsx | 347 ++++++++++++------------------ 5 files changed, 295 insertions(+), 277 deletions(-) create mode 100644 src/components/LoadingSpinner.tsx diff --git a/package-lock.json b/package-lock.json index f9c22ee..54822f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "^6.21.1", + "react-spinners": "^0.14.1", "vite-plugin-svgr": "^4.3.0" }, "devDependencies": { @@ -5105,6 +5106,16 @@ "react-dom": ">=16.8" } }, + "node_modules/react-spinners": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.14.1.tgz", + "integrity": "sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", diff --git a/package.json b/package.json index 44c0518..96a24c0 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-router-dom": "^6.21.1", + "react-spinners": "^0.14.1", "vite-plugin-svgr": "^4.3.0" }, "devDependencies": { diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..0db9d84 --- /dev/null +++ b/src/components/LoadingSpinner.tsx @@ -0,0 +1,42 @@ +import fishIcon from "../assets/fishIcon.svg"; +import {SyncLoader} from "react-spinners"; +import {useEffect, useState} from "react"; +import styled from "@emotion/styled"; +import { Text } from "@chakra-ui/react"; +export default function LoadingSpinner({timeout = 200}) { + const [showSpinner, setShowSpinner] = useState(false); + + /** + * [timeout]ms 후에 spinner를 보여준다. + */ + useEffect(() => { + const timer = setTimeout(() => { + setShowSpinner(true); + }, timeout); + + return () => clearTimeout(timer); // 메모리 누수 방지 + }, []); + + + return ( + + {showSpinner && ( + <> + +
+ + 물고기 박사님이 확인 중이에요~ + + )} + + ); +} + +const Wrapper = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +`; \ No newline at end of file diff --git a/src/pages/Camera/CameraPage.tsx b/src/pages/Camera/CameraPage.tsx index 434a977..0a04d8a 100644 --- a/src/pages/Camera/CameraPage.tsx +++ b/src/pages/Camera/CameraPage.tsx @@ -2,17 +2,20 @@ import React, { useRef, useState, useEffect } from "react"; import styled from "@emotion/styled"; import { useNavigate } from "react-router-dom"; import HomeIcon from "../../components/icons/HomeIcon"; -import { Button, IconButton } from "@chakra-ui/react"; +import { Button, IconButton, useToast } from "@chakra-ui/react"; import { CameraIcon } from "lucide-react"; +import axios from "axios"; +import LoadingSpinner from "../../components/LoadingSpinner"; const CameraPage: React.FC = () => { const navigate = useNavigate(); + const toast = useToast(); const [isCameraActive, setIsCameraActive] = useState(false); const [capturedImage, setCapturedImage] = useState(null); + const [loading, setLoading] = useState(false); // 로딩 상태 추가 const videoRef = useRef(null); const canvasRef = useRef(null); - // 페이지 로드 시 카메라 시작 useEffect(() => { startCamera(); return () => stopCamera(); // 페이지 떠날 때 카메라 정지 @@ -42,23 +45,64 @@ const CameraPage: React.FC = () => { const video = videoRef.current; canvas.width = video.videoWidth; canvas.height = video.videoHeight; - - // 현재 비디오 프레임을 캔버스에 그리기 const context = canvas.getContext("2d"); if (context) { context.drawImage(video, 0, 0, canvas.width, canvas.height); - // 캔버스 내용을 base64 형식으로 변환하여 이미지 데이터 저장 const imageData = canvas.toDataURL("image/png"); setCapturedImage(imageData); } } }; - const stopAndCapture = () => { captureImage(); stopCamera(); }; + const analyzeImage = async () => { + if (!capturedImage) { + toast({ + title: "분석할 이미지가 없습니다.", + status: "warning", + duration: 2000, + isClosable: true, + }); + return; + } + setLoading(true); // 로딩 상태 활성화 + try { + // 1단계: AI API로 이미지 전송 + const aiResponse = await axios.post("/ai/analyze", { image: capturedImage }); + const fishData = aiResponse.data.fishInfo; + + if (!fishData || fishData.length === 0) { + setLoading(false); + navigate("/notfound"); + return; + } + + // 2단계: 물고기 데이터 백엔드로 전송 + const backendResponse = await axios.post( + "/api/v1/pokedex/update", + { caughtPokemons: fishData }, + { headers: { "Content-Type": "application/json" } } + ); + + // 3단계: 분석 페이지로 이동 + const { pokemonStatus } = backendResponse.data; + navigate("/analysis", { state: { pokemonStatus } }); + } catch (error) { + toast({ + title: "분석 실패", + description: "다시 시도해주세요.", + status: "error", + duration: 3000, + isClosable: true, + }); + } finally { + setLoading(false); // 로딩 상태 비활성화 + } + }; + return ( { left="0" zIndex="10" /> - {/* */} - - @@ -144,11 +187,6 @@ const Wrapper = styled.div` align-items: center; `; -const MainFish = styled.img` - margin-top: 40px; - width: 200px; -`; - const CameraWrapper = styled.div` display: flex; flex-direction: column; @@ -156,7 +194,6 @@ const CameraWrapper = styled.div` height: 90%; `; -// styled-component에서 props를 받아 다른 스타일 적용 interface BottomButtonProps { isPrimary?: boolean; } @@ -167,7 +204,6 @@ const BottomButton = styled(Button)` width: 40%; height: 70px; font-weight: 300; - height: 40px; font-size: 24px; border-radius: 10px; border: none; @@ -177,13 +213,8 @@ const BottomButton = styled(Button)` text-align: center; ${({ isPrimary }) => isPrimary - ? ` - left: 30px; - ` - : ` - right: 30px; - `} - + ? `left: 30px;` + : `right: 30px;`} &:hover { background-color: #C5EFFF; @@ -191,7 +222,7 @@ const BottomButton = styled(Button)` &:active { background-color: #55CFFF; - transform: scale(0.95); /* 눌렀을 때 살짝 축소 효과 */ - box-shadow: 0 0 20px rgba(85, 207, 255, 0.6); /* 번지는 효과 */ + transform: scale(0.95); + box-shadow: 0 0 20px rgba(85, 207, 255, 0.6); } `; diff --git a/src/pages/Start/SignupPage.tsx b/src/pages/Start/SignupPage.tsx index 5bbbd4b..c627767 100644 --- a/src/pages/Start/SignupPage.tsx +++ b/src/pages/Start/SignupPage.tsx @@ -1,218 +1,151 @@ -import React, { useRef, useState, useEffect } from "react"; +import React, { useState } from "react"; import styled from "@emotion/styled"; -import { useNavigate } from "react-router-dom"; -import HomeIcon from "../../components/icons/HomeIcon"; -import { Button, IconButton, useToast } from "@chakra-ui/react"; -import { CameraIcon } from "lucide-react"; +import { Flex, Text, keyframes, Box, useToast } from "@chakra-ui/react"; import axios from "axios"; -import { API_BASE_URL} from "../../api/constant.ts"; -const CameraPage: React.FC = () => { - const navigate = useNavigate(); - const toast = useToast(); - const [isCameraActive, setIsCameraActive] = useState(false); - const [capturedImage, setCapturedImage] = useState(null); - const videoRef = useRef(null); - const canvasRef = useRef(null); - - useEffect(() => { - startCamera(); - return () => stopCamera(); // 페이지 떠날 때 카메라 정지 - }, []); - - const startCamera = async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ video: true }); - if (videoRef.current) { - videoRef.current.srcObject = stream; - setIsCameraActive(true); - } - } catch (error) { - console.error("카메라 접근 오류:", error); - } - }; - - const stopCamera = () => { - const stream = videoRef.current?.srcObject as MediaStream; - stream?.getTracks().forEach(track => track.stop()); - setIsCameraActive(false); - }; - - const captureImage = () => { - if (videoRef.current && canvasRef.current) { - const canvas = canvasRef.current; - const video = videoRef.current; - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - const context = canvas.getContext("2d"); - if (context) { - context.drawImage(video, 0, 0, canvas.width, canvas.height); - const imageData = canvas.toDataURL("image/png"); - setCapturedImage(imageData); - } - } - }; - - const stopAndCapture = () => { - captureImage(); - stopCamera(); - }; - - const analyzeImage = async () => { - if (!capturedImage) { - toast({ - title: "분석할 이미지가 없습니다.", - status: "warning", - duration: 2000, - isClosable: true, - }); - return; - } - try { - // 1단계: AI API로 이미지 전송 - const aiResponse = await axios.post("/ai/analyze", { image: capturedImage }); - const fishData = aiResponse.data.fishInfo; - - if (!fishData || fishData.length === 0) { - navigate("/notfound"); - return; - } - - // 2단계: 물고기 데이터 백엔드로 전송 - const backendResponse = await axios.post( - `${API_BASE_URL}/api/v1/pokedex/update`, - { caughtPokemons: fishData }, - { headers: { "Content-Type": "application/json" } } - ); - - // 3단계: 분석 페이지로 이동 - const { pokemonStatus } = backendResponse.data; - navigate("/analysis", { state: { pokemonStatus } }); - } catch (error) { - toast({ - title: "분석 실패", - description: "다시 시도해주세요.", - status: "error", - duration: 3000, - isClosable: true, - }); - } - }; - - return ( - - } - aria-label="홈으로 이동" - onClick={() => navigate("/main")} - variant="ghost" - _hover={{ bg: "transparent" }} - _active={{ bg: "transparent" }} - _focus={{ boxShadow: "none" }} - position="absolute" - left="0" - zIndex="10" - /> - - - - - ); +import background_sea from "../../assets/background_sea.svg"; +import background_sea_phone from "../../assets/background_sea_phone.svg"; +import backgroundFish1 from "../../assets/background_fish1.svg"; +import backgroundFish2 from "../../assets/background_fish2.svg"; +import WhiteInput from "../../components/WhiteInput.tsx"; +import PasswordInput from "../../components/PasswordInput.tsx"; +import NextButton from "../../components/NextButton.tsx"; +import CheckDuplicateEmail from "../../components/CheckDuplicateEmail.tsx"; +import { useNavigate } from "react-router-dom"; +import start_img from "../../assets/start_img.svg"; +import { API_BASE_URL } from "../../api/constant.ts"; + +const SignupPage: React.FC = () => { + const navigate = useNavigate(); + const toast = useToast(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [nickName, setNickname] = useState(""); + const [animate, setAnimate] = useState(false); + + const handleSignup = async () => { + try { + const response = await axios.post(`${API_BASE_URL}/api/v1/user/signup`, { + email, + nickName, + password, + }, + { + headers: { + "Content-Type": "application/json", + }}); + + if (response.status === 200) { + toast({ + title: "회원가입이 완료되었습니다", + status: "success", + duration: 3000, + isClosable: true, + }); + setAnimate(true); + setTimeout(() => { + navigate("/main"); + }, 1300); + } + } catch (error) { + toast({ + title: "회원가입에 실패했습니다", + description: error.response?.data?.message || "다시 시도해주세요", + status: "error", + duration: 3000, + isClosable: true, + }); + } + }; + + const handleChangeEmail = (e: React.ChangeEvent) => { + setEmail(e.target.value); + }; + const handleChangePw = (e: React.ChangeEvent) => { + setPassword(e.target.value); + }; + const handleChangeName = (e: React.ChangeEvent) => { + setNickname(e.target.value); + }; + + return ( + + + + + + + + + + + + + + + 가입하기 + + {/* 화면 밖에서 시작해 오른쪽으로 지나가는 애니메이션 */} + + + ); }; -export default CameraPage; +export default SignupPage; -const Wrapper = styled.div` - background-color: #E9F9FF; - width: 100%; - height: 100vh; - position: relative; - display: flex; - flex-direction: column; - align-items: center; +const floatAnimation = keyframes` + 0%, 100% { transform: translateX(-300%); } `; - -const CameraWrapper = styled.div` - display: flex; - flex-direction: column; - align-items: center; - height: 90%; +// 천천히 나타나고 빠르게 사라지게 하는 애니메이션 정의 +const slideInOutAnimation = keyframes` + 0% { transform: translateX(-300%); } + 50% { transform: translateX(20%); } + 100% { transform: translateX(300%); } `; -interface BottomButtonProps { - isPrimary?: boolean; -} - -const BottomButton = styled(Button)` - background-color: #FFFFFF; - color: #05518F; - width: 40%; - height: 70px; - font-weight: 300; - font-size: 24px; - border-radius: 10px; - border: none; - box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2); - position: absolute; - bottom: 20px; - text-align: center; - ${({ isPrimary }) => - isPrimary - ? `left: 30px;` - : `right: 30px;`} +const AnimatedFish = styled.img` + width: 500px; + position: absolute; + top: 40%; + z-index: 5; + animation: ${floatAnimation} 3s ease-in-out infinite; /* 둥둥 떠있는 애니메이션 */ + &.animate-in { + animation: ${slideInOutAnimation} 3s ease-in-out forwards; + } +`; - &:hover { - background-color: #C5EFFF; - } +const Wrapper = styled.div` + @media (min-width: 600px) { + background-image: url(${background_sea}); + } + background-image: url(${background_sea_phone}); + width: 100%; + height: 100vh; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + overflow: hidden; +`; - &:active { - background-color: #55CFFF; - transform: scale(0.95); - box-shadow: 0 0 20px rgba(85, 207, 255, 0.6); - } +const swimAnimation = keyframes` + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } `;