diff --git a/README.md b/README.md index 41b323d..fec1fdd 100644 --- a/README.md +++ b/README.md @@ -1 +1,120 @@ # FrontEnd + +## 휴머니케어 - 실질적 독거노인을 위한 음성 상호작용 앱 + +**"혼자 계신 부모님이 걱정되시나요?"** +이 앱은 실질적으로 혼자 거주하시는 고령자를 위해 설계되었습니다. +보호자가 미리 지정한 키워드를 통해 질문을 음성으로 전달하고, +어르신은 그 IoT에 응답함으로써 일상 상태를 간단히 공유할 수 있습니다. +또한 보호자의 음성을 학습시켜 고령자에게 더 친근하고 정서적인 상호작용이 가능합니다. + + +### 프로젝트 실행 방법 + +#### 1. 프로젝트 클론 +```bash +git clone https://github.com/HumaniCare/FrontEnd.git +cd FrontEnd +``` + +#### 2. 필요한 패키지 설치 +```bash +npm install +``` + +#### 3. 개발 서버 실행 +```bash +npm start +``` +기본 포트는 `http://localhost:3000`입니다. + + +### 주요 의존성 + +다음은 `package.json` 기준으로 프로젝트에 사용된 주요 라이브러리입니다: + +| 패키지명 | 설명 | +|---------|------| +| `react` | React 18 기반의 SPA 개발 | +| `react-router-dom` | 페이지 라우팅 처리 | +| `axios` | HTTP 통신 처리 (Spring/FASTAPI와 연동) | +| `mic-recorder-to-mp3` | 브라우저 마이크 녹음 및 mp3 변환 | +| `react-time-picker` | 시간 선택 기능 제공 | +| `@testing-library/react` 외 | 테스트용 도구들 | + + +### 음성 학습 기능 + +- 마이크 버튼을 누르면 음성이 녹음되고, mp3 형식으로 변환됩니다. +- 사용자가 직접 재생/삭제/전송할 수 있으며, 전송 시 FastAPI 서버로 POST 요청됩니다. +- 저장된 음성은 보호자 앱 또는 서버에서 활용 가능합니다. + + +### 기타 참고 + +- 녹음 전 `마이크 권한`을 요청합니다. 브라우저 설정에서 허용이 필요합니다. +- 실서비스 배포 시 HTTPS 환경과 CORS 설정 확인 필수입니다. + + +--- + +## HumaniCare – A Voice Interaction App for Elderly Living Alone + +**"Worried about your parents living alone?"** +This application is designed for elderly individuals who live independently. +Guardians can set predefined questions that are delivered via voice to the elderly user, +who can then respond through a simple IoT interface. +Additionally, the app supports voice training using the guardian’s own voice, enabling more emotional and comforting interactions. + +--- + +### How to Run the Project + +#### 1. Clone the Repository +```bash +git clone https://github.com/HumaniCare/FrontEnd.git +cd FrontEnd +``` + +#### 2. Install Required Packages +```bash +npm install +``` + +#### 3. Start the Development Server +```bash +npm start +``` + +The default development URL is `http://localhost:3000`. + +--- + +### Main Dependencies + +The following are the key libraries used in the project based on `package.json`: + +| Package | Description | +|---------|-------------| +| `react` | React 18 for single-page application development | +| `react-router-dom` | Routing between pages | +| `axios` | HTTP communication with Spring/FastAPI servers | +| `mic-recorder-to-mp3` | Recording audio from the browser and converting it to MP3 | +| `react-time-picker` | Provides time selection UI | +| `@testing-library/react` etc. | Tools for component testing | + +--- + +### Voice Training Feature + +- Users can record audio by clicking the mic button. The recording is converted to an MP3 file. +- The user can preview, delete, or upload the file. +- Upon uploading, the audio file is sent to the FastAPI server via a `POST` request. +- The saved voice data can later be used in the guardian’s app or backend services. + +--- + +### Notes + +- Microphone permissions are requested before recording. Please allow access in your browser. +- For production deployment, make sure to use **HTTPS** and properly configure **CORS** on the backend. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1e73e38..168e484 100755 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.8.4", + "mic-recorder-to-mp3": "^2.2.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.17.0", @@ -11131,6 +11132,15 @@ "node": ">= 8" } }, + "node_modules/lamejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/lamejs/-/lamejs-1.2.1.tgz", + "integrity": "sha512-s7bxvjvYthw6oPLCm5pFxvA84wUROODB8jEO2+CE1adhKgrIvVOlmMgY8zyugxGrvRaDHNJanOiS21/emty6dQ==", + "license": "LGPL-3.0", + "dependencies": { + "use-strict": "1.0.1" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -11463,6 +11473,18 @@ "node": ">= 0.6" } }, + "node_modules/mic-recorder-to-mp3": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/mic-recorder-to-mp3/-/mic-recorder-to-mp3-2.2.2.tgz", + "integrity": "sha512-xDkOaHbojW3bdKOGn9CI5dT+Mc0RrfczsX/Y1zGJp3FUB4zei5ZKFnNm7Nguc9v910wkd7T3csnCTq5EtCF3Zw==", + "license": "MIT", + "dependencies": { + "lamejs": "^1.2.0" + }, + "peerDependencies": { + "webrtc-adapter": ">=4.1.1" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -14915,6 +14937,13 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz", + "integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==", + "license": "MIT", + "peer": true + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -16791,6 +16820,12 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-strict": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/use-strict/-/use-strict-1.0.1.tgz", + "integrity": "sha512-IeiWvvEXfW5ltKVMkxq6FvNf2LojMKvB2OCeja6+ct24S1XOmQw2dGr2JyndwACWAGJva9B7yPHwAmeA9QCqAQ==", + "license": "ISC" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -17160,6 +17195,20 @@ "node": ">=4.0" } }, + "node_modules/webrtc-adapter": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz", + "integrity": "sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/package.json b/package.json index 9317187..1af976f 100755 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.8.4", + "mic-recorder-to-mp3": "^2.2.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.17.0", diff --git a/src/components/MicButton.js b/src/components/MicButton.js index d1a8f0d..045ac06 100644 --- a/src/components/MicButton.js +++ b/src/components/MicButton.js @@ -1,9 +1,55 @@ -import React from "react"; +import React, { useState } from "react"; +import MicRecorder from "mic-recorder-to-mp3"; +import { FASTAPI_API_URL } from "../constants/api"; + +const recorder = new MicRecorder({ bitRate: 128 }); + +const MicButton = () => { + const [isRecording, setIsRecording] = useState(false); + + const handleMicClick = () => { + // 마이크 명시적으로 권한 요청 + navigator.mediaDevices.getUserMedia({ audio: true }) + .then(() => { + // 권한 허용됨 + if (!isRecording) { + recorder.start().then(() => setIsRecording(true)); + } else { + recorder.stop() + .getMp3() + .then(([buffer, blob]) => { + setIsRecording(false); + const file = new File(buffer, "voice.mp3", { + type: blob.type, + lastModified: Date.now(), + }); + + const formData = new FormData(); + formData.append("file", file); + + fetch(`${FASTAPI_API_URL}/upload`, { + method: "POST", + body: formData, + }) + .then(res => res.json()) + .then(data => { + console.log("업로드 완료:", data); + }) + .catch(err => console.error("업로드 실패:", err)); + }) + .catch(e => console.error("녹음 종료 실패:", e)); + } + }) + .catch((err) => { + alert("마이크 권한이 필요합니다."); + console.error("마이크 권한 거부:", err); + }); + }; -const MicButton = ({onClick}) => { return ( - ); }; @@ -14,6 +60,9 @@ const styles = { border: "none", cursor: "pointer", marginBottom: "20px", + display: "flex", + flexDirection: "column", + alignItems: "center" }, micIcon: { width: "60px", diff --git a/src/components/oauth/KakaoRedirectPage.js b/src/components/oauth/KakaoRedirectPage.js index 831a64d..78ec41d 100644 --- a/src/components/oauth/KakaoRedirectPage.js +++ b/src/components/oauth/KakaoRedirectPage.js @@ -1,7 +1,8 @@ import React, { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import instance from "../../axios/TokenInterceptor"; -import { SPRING_API_URL } from "../constants/api"; +import { SPRING_API_URL } from "../../constants/api"; + const KakaoRedirectPage = () => { const location = useLocation(); @@ -21,7 +22,7 @@ const KakaoRedirectPage = () => { localStorage.setItem("accessToken", accessToken); const role = response.data.result; - if (role === "ROLE_FIRST") { + if (role === "FIRST") { navigate("/voice-training"); } else { navigate("/home"); diff --git a/src/pages/FinalPage.js b/src/pages/FinalPage.js index a8975c6..5d75a1e 100644 --- a/src/pages/FinalPage.js +++ b/src/pages/FinalPage.js @@ -1,11 +1,9 @@ // FinalPage.jsx import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import Logo from "../components/Logo"; import { Logout } from "../axios/TokenInterceptor"; // 실제 axios 파일 경로에 맞게 수정 const FinalPage = () => { - const navigate = useNavigate(); const [showConfirm, setShowConfirm] = useState(false); const handleLogoutClick = () => { diff --git a/src/pages/KeywordSelectionPage.js b/src/pages/KeywordSelectionPage.js index 39cd16e..c248de6 100644 --- a/src/pages/KeywordSelectionPage.js +++ b/src/pages/KeywordSelectionPage.js @@ -129,7 +129,17 @@ const KeywordSelectionPage = () => { console.log("JSON 파싱 직전"); console.log(schedules); - const updatedSelected = JSON.parse(JSON.stringify(selected)); + const updatedSelected = Object.fromEntries( + Object.entries(keywords).map(([category, list]) => [ + category, + Object.fromEntries( + list.map((keyword) => [ + keyword, + { selected: false, time: "08:00", days: [] } + ]) + ) + ]) + ); schedules.forEach(item => { const [categoryPrefix, keyword] = item.scheduleTitle.split("_"); diff --git a/src/pages/LoginPage.js b/src/pages/LoginPage.js index b67d018..ae28ac6 100644 --- a/src/pages/LoginPage.js +++ b/src/pages/LoginPage.js @@ -1,45 +1,49 @@ import React from "react"; -import { useNavigate } from "react-router-dom"; -import Button from "../components/Button"; -import Logo from "../components/Logo"; import { SPRING_API_URL } from "../constants/api"; +import Logo from "../components/Logo"; const LoginPage = () => { - const navigate = useNavigate(); const handleKakaoLogin = () => { - window.location.href = `${ SPRING_API_URL}/oauth/kakao`; + window.location.href = `${SPRING_API_URL}/oauth/kakao`; }; return ( -
+
-

회원가입하기

- -
); }; const styles = { container: { - display : "flex", - flexDirection : "column", - alignItems : "center", + display: "flex", + flexDirection: "column", + alignItems: "center", justifyContent: "center", height: "100vh", backgroundColor: "#FAE8D4", + padding: "0 20px", + textAlign: "center", }, - title: { - fontSize : "20px", - marginBottom: "20px", + message: { + fontSize: "18px", + color: "#333", + margin: "30px 0 40px", + lineHeight: "1.5", + fontWeight: "500", }, - signupButton: { - backgroundColor: "transparent", - border: "1px solid black", - padding: "10px 20px", - borderRadius: "8px", - fontSize: "16px", - marginBottom: "20px", + kakaoButton: { + width: "250px", cursor: "pointer", }, }; diff --git a/src/pages/VoiceTrainingPage.js b/src/pages/VoiceTrainingPage.js index 3eefa4f..d3a4153 100644 --- a/src/pages/VoiceTrainingPage.js +++ b/src/pages/VoiceTrainingPage.js @@ -1,10 +1,71 @@ -import React from "react"; +import React, { useState, useRef } from "react"; import { useNavigate } from "react-router-dom"; -import MicButton from "../components/MicButton"; +import MicRecorder from "mic-recorder-to-mp3"; import Logo from "../components/Logo"; +import { FASTAPI_API_URL } from "../constants/api"; + +const recorder = new MicRecorder({ bitRate: 128 }); const VoiceTrainingPage = () => { const navigate = useNavigate(); + const [isRecording, setIsRecording] = useState(false); + const [blobURL, setBlobURL] = useState(""); + const [audioFile, setAudioFile] = useState(null); + const audioRef = useRef(null); + + const handleMicClick = async () => { + if (!isRecording) { + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + await recorder.start(); + setIsRecording(true); + } catch (err) { + alert("마이크 권한이 필요합니다."); + } + } else { + try { + const [buffer, blob] = await recorder.stop().getMp3(); + const file = new File(buffer, "voice.mp3", { + type: blob.type, + lastModified: Date.now(), + }); + setAudioFile(file); + setBlobURL(URL.createObjectURL(blob)); + setIsRecording(false); + } catch (e) { + console.error("녹음 종료 실패:", e); + setIsRecording(false); + } + } + }; + + const handleUpload = async () => { + if (!audioFile) { + alert("녹음된 음성이 없습니다."); + return; + } + + const formData = new FormData(); + formData.append("file", audioFile); + + try { + const res = await fetch(`${FASTAPI_API_URL}/upload`, { + method: "POST", + body: formData, + }); + const data = await res.json(); + console.log("업로드 성공:", data); + alert("업로드 완료!"); + } catch (error) { + console.error("업로드 실패:", error); + alert("업로드 실패"); + } + }; + + const handleReset = () => { + setBlobURL(""); + setAudioFile(null); + }; return (
@@ -13,7 +74,6 @@ const VoiceTrainingPage = () => {

(아래의 마이크 버튼을 누르고 텍스트를 읽어주세요.)

- Memo

오늘 하루는 어땠나요? 기분이 괜찮으신가요?

밖에 나가서 산책도 하셨어요?

@@ -23,7 +83,20 @@ const VoiceTrainingPage = () => {
- + + + {blobURL && ( +
+
+ )}