Skip to content

Commit

Permalink
feat: 분석 중 로딩 스피너 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
Catleap02 committed Nov 15, 2024
1 parent 991ec39 commit e7d5e06
Show file tree
Hide file tree
Showing 5 changed files with 295 additions and 277 deletions.
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
42 changes: 42 additions & 0 deletions src/components/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Wrapper>
{showSpinner && (
<>
<img className="w-[60px] h-[60px]" src={fishIcon}/>
<div className="h-[12px]"/>
<SyncLoader color="#59CAFC" size={10}/>
<Text mt="10px" fontSize='1xl' color="white">물고기 박사님이 확인 중이에요~</Text>
</>
)}
</Wrapper>
);
}

const Wrapper = styled.div`
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
`;
171 changes: 101 additions & 70 deletions src/pages/Camera/CameraPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [loading, setLoading] = useState(false); // 로딩 상태 추가
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);

// 페이지 로드 시 카메라 시작
useEffect(() => {
startCamera();
return () => stopCamera(); // 페이지 떠날 때 카메라 정지
Expand Down Expand Up @@ -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 (
<Wrapper>
<IconButton
Expand All @@ -73,59 +117,58 @@ const CameraPage: React.FC = () => {
left="0"
zIndex="10"
/>
{/* <MainFish src={start_img} alt="fish" />*/}


<CameraWrapper>
<video ref={videoRef} autoPlay style={{ width: "100%", height: "90%", marginTop: "0px", display: isCameraActive ? "block" : "none", objectFit: "cover"}} />

{/* 사진 캡처 버튼 */}
{isCameraActive && (
<Button onClick={stopAndCapture} mt='40px' mb="10px" borderRadius="50%" bg='white' border="1px solid black" color='black' w={16} h={16} boxShadow="0px 4px 8px rgba(0, 0, 0, 0.2)">
<CameraIcon size="40" />
</Button>
)}

{/* 캡처된 사진 미리보기 및 하단 버튼 */}
{!isCameraActive && (
{loading ? (
<LoadingSpinner /> // 로딩 중일 때 LoadingSpinner 표시
) : (
<>
<img src={capturedImage || ""} alt="캡처된 이미지" style={{ width: "100%", height: "90%", marginTop: "0px", objectFit: "cover" }} />

{/* 다시 찍기 버튼 */}
<BottomButton
onClick={startCamera}
isPrimary
mt="10px"
mb="10px"
borderRadius="50%"
bg="white"
border="1px solid black"
color="black"
w={16}
h={16}
boxShadow="0px 4px 8px rgba(0, 0, 0, 0.2)"
>
다시 찍기
</BottomButton>

{/* 분석하기 버튼 */}
<BottomButton
mt="10px"
mb="10px"
borderRadius="50%"
bg="white"
border="1px solid black"
color="black"
w={16}
h={16}
boxShadow="0px 4px 8px rgba(0, 0, 0, 0.2)"
>
분석하기
</BottomButton>
<video ref={videoRef} autoPlay style={{ width: "100%", height: "90%", display: isCameraActive ? "block" : "none", objectFit: "cover"}} />

{isCameraActive && (
<Button onClick={stopAndCapture} mt='40px' mb="10px" borderRadius="50%" bg='white' border="1px solid black" color='black' w={16} h={16} boxShadow="0px 4px 8px rgba(0, 0, 0, 0.2)">
<CameraIcon size="40" />
</Button>
)}

{!isCameraActive && (
<>
<img src={capturedImage || ""} alt="캡처된 이미지" style={{ width: "100%", height: "90%", objectFit: "cover" }} />

<BottomButton
onClick={startCamera}
isPrimary
mt="10px"
mb="10px"
borderRadius="50%"
bg="white"
border="1px solid black"
color="black"
w={16}
h={16}
boxShadow="0px 4px 8px rgba(0, 0, 0, 0.2)"
>
다시 찍기
</BottomButton>

<BottomButton
onClick={analyzeImage}
mt="10px"
mb="10px"
borderRadius="50%"
bg="white"
border="1px solid black"
color="black"
w={16}
h={16}
boxShadow="0px 4px 8px rgba(0, 0, 0, 0.2)"
>
분석하기
</BottomButton>
</>
)}
</>
)}

{/* 캡처를 위한 캔버스 (보이지 않게 숨김) */}
<canvas ref={canvasRef} style={{ display: "none" }} />
</CameraWrapper>
</Wrapper>
Expand All @@ -144,19 +187,13 @@ 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;
align-items: center;
height: 90%;
`;

// styled-component에서 props를 받아 다른 스타일 적용
interface BottomButtonProps {
isPrimary?: boolean;
}
Expand All @@ -167,7 +204,6 @@ const BottomButton = styled(Button)<BottomButtonProps>`
width: 40%;
height: 70px;
font-weight: 300;
height: 40px;
font-size: 24px;
border-radius: 10px;
border: none;
Expand All @@ -177,21 +213,16 @@ const BottomButton = styled(Button)<BottomButtonProps>`
text-align: center;
${({ isPrimary }) =>
isPrimary
? `
left: 30px;
`
: `
right: 30px;
`}
? `left: 30px;`
: `right: 30px;`}
&:hover {
background-color: #C5EFFF;
}
&: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);
}
`;
Loading

0 comments on commit e7d5e06

Please sign in to comment.