diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e600336
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,4 @@
+node_modules
+build
+.git
+.github
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..0e7d4ce
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,22 @@
+name: Build and Push React Docker Image
+
+on:
+ push:
+ branches: [main, dev, feat/dockerconnect]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Build and push Docker image
+ run: |
+ docker build -t ${{ secrets.DOCKER_USERNAME }}/react:latest .
+ docker push ${{ secrets.DOCKER_USERNAME }}/react:latest
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..c3f502a
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# 디폴트 무시된 파일
+/shelf/
+/workspace.xml
+# 에디터 기반 HTTP 클라이언트 요청
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/humanicare.iml b/.idea/humanicare.iml
new file mode 100644
index 0000000..d6ebd48
--- /dev/null
+++ b/.idea/humanicare.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..03d9549
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..07115cd
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..594f21c
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..bc39de7
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,14 @@
+# 빌드 단계
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package*.json ./
+RUN npm install
+COPY . .
+RUN npm run build
+
+# 배포 단계
+FROM nginx:alpine
+COPY --from=build /app/build /usr/share/nginx/html
+COPY default.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]
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/default.conf b/default.conf
new file mode 100644
index 0000000..9662df9
--- /dev/null
+++ b/default.conf
@@ -0,0 +1,9 @@
+server {
+ listen 80;
+ server_name www.humanicare.store;
+ root /usr/share/nginx/html;
+ index index.html;
+ location / {
+ try_files $uri /index.html;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 42a86c8..168e484 100755
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,9 +12,13 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0",
- "react": "^19.0.0",
- "react-dom": "^19.0.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",
"react-scripts": "5.0.1",
+ "react-time-picker": "^7.0.0",
"web-vitals": "^2.1.4"
}
},
@@ -3090,6 +3094,15 @@
}
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.10.0.tgz",
+ "integrity": "sha512-Lm+fYpMfZoEucJ7cMxgt4dYt8jLfbpwRCzAjm9UgSLOkmlqo9gupxt6YX3DY0Fk155NT9l17d/ydi+964uS9Lw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -4310,6 +4323,15 @@
"@xtuc/long": "4.2.2"
}
},
+ "node_modules/@wojtekmaj/date-utils": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@wojtekmaj/date-utils/-/date-utils-1.5.1.tgz",
+ "integrity": "sha512-+i7+JmNiE/3c9FKxzWFi2IjRJ+KzZl1QPu6QNrsgaa2MuBgXvUy4gA1TVzf/JMdIIloB76xSKikTWuyYAIVLww==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/wojtekmaj/date-utils?sponsor=1"
+ }
+ },
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -4897,6 +4919,32 @@
"node": ">=4"
}
},
+ "node_modules/axios": {
+ "version": "1.8.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
+ "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/axios/node_modules/form-data": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
+ "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -5635,6 +5683,15 @@
"wrap-ansi": "^7.0.0"
}
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -6572,6 +6629,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/detect-element-overflow": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/detect-element-overflow/-/detect-element-overflow-1.4.2.tgz",
+ "integrity": "sha512-4m6cVOtvm/GJLjo7WFkPfwXoEIIbM7GQwIh4WEa4g7IsNi1YzwUsGL5ApNLrrHL29bHeNeQ+/iZhw+YHqgE2Fw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/wojtekmaj/detect-element-overflow?sponsor=1"
+ }
+ },
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -8575,6 +8641,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-user-locale": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-2.3.2.tgz",
+ "integrity": "sha512-O2GWvQkhnbDoWFUJfaBlDIKUEdND8ATpBXD6KXcbhxlfktyD/d8w6mkzM/IlQEqGZAMz/PW6j6Hv53BiigKLUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mem": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1"
+ }
+ },
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -11054,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",
@@ -11262,6 +11349,15 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/make-event-props": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.2.tgz",
+ "integrity": "sha512-iDwf7mA03WPiR8QxvcVHmVWEPfMY1RZXerDVNCRYW7dUr2ppH3J58Rwb39/WG39yTZdRSxr3x+2v22tvI0VEvA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
+ }
+ },
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -11271,6 +11367,18 @@
"tmpl": "1.0.5"
}
},
+ "node_modules/map-age-cleaner": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz",
+ "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-defer": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -11295,6 +11403,31 @@
"node": ">= 0.6"
}
},
+ "node_modules/mem": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz",
+ "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==",
+ "license": "MIT",
+ "dependencies": {
+ "map-age-cleaner": "^0.1.3",
+ "mimic-fn": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/mem?sponsor=1"
+ }
+ },
+ "node_modules/mem/node_modules/mimic-fn": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz",
+ "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/memfs": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz",
@@ -11340,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",
@@ -11885,6 +12030,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/p-defer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
+ "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@@ -13629,6 +13783,12 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -13757,10 +13917,13 @@
}
},
"node_modules/react": {
- "version": "19.0.0",
- "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
- "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
"engines": {
"node": ">=0.10.0"
}
@@ -13788,6 +13951,30 @@
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
+ "node_modules/react-clock": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/react-clock/-/react-clock-5.1.0.tgz",
+ "integrity": "sha512-DKmr29VOK6M8wpbzGUZZa9PwGnG9uC6QXtDLwGwcc2r3vdS/HxNhf5xMMjudXLk7m096mNJQf7AgfjiDpzAYYw==",
+ "license": "MIT",
+ "dependencies": {
+ "@wojtekmaj/date-utils": "^1.5.0",
+ "clsx": "^2.0.0",
+ "get-user-locale": "^2.2.1"
+ },
+ "funding": {
+ "url": "https://github.com/wojtekmaj/react-clock?sponsor=1"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-dev-utils": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
@@ -13894,15 +14081,16 @@
}
},
"node_modules/react-dom": {
- "version": "19.0.0",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
- "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
- "scheduler": "^0.25.0"
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
},
"peerDependencies": {
- "react": "^19.0.0"
+ "react": "^18.3.1"
}
},
"node_modules/react-error-overlay": {
@@ -13911,6 +14099,32 @@
"integrity": "sha512-SN/U6Ytxf1QGkw/9ve5Y+NxBbZM6Ht95tuXNMKs8EJyFa/Vy/+Co3stop3KBHARfn/giv+Lj1uUnTfOJ3moFEQ==",
"license": "MIT"
},
+ "node_modules/react-fit": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/react-fit/-/react-fit-2.0.1.tgz",
+ "integrity": "sha512-Eip6ALs/+6Jv82Si0I9UnfysdwVlAhkkZRycgmMdnj7jwUg69SVFp84ICxwB8zszkfvJJ2MGAAo9KAYM8ZUykQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-element-overflow": "^1.4.0",
+ "warning": "^4.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/wojtekmaj/react-fit?sponsor=1"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -13926,6 +14140,38 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "6.17.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.17.0.tgz",
+ "integrity": "sha512-YJR3OTJzi3zhqeJYADHANCGPUu9J+6fT5GLv82UWRGSxu6oJYCKVmxUcaBQuGm9udpWmPsvpme/CdHumqgsoaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.10.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.17.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.17.0.tgz",
+ "integrity": "sha512-qWHkkbXQX+6li0COUUPKAUkxjNNqPJuiBd27dVwQGDNsuFBdMbrS6UZ0CLYc4CsbdLYTckn4oB4tGDuPZpPhaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.10.0",
+ "react-router": "6.17.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
"node_modules/react-scripts": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
@@ -13999,6 +14245,34 @@
}
}
},
+ "node_modules/react-time-picker": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/react-time-picker/-/react-time-picker-7.0.0.tgz",
+ "integrity": "sha512-k6mUjkI+OsY73mg0yjMxqkLXv/UXR1LN7AARNqfyGZOwqHqo1JrjL3lLHTHWQ86HmPTBL/dZACbIX/fV1NLmWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@wojtekmaj/date-utils": "^1.1.3",
+ "clsx": "^2.0.0",
+ "get-user-locale": "^2.2.1",
+ "make-event-props": "^1.6.0",
+ "react-clock": "^5.0.0",
+ "react-fit": "^2.0.0",
+ "update-input-width": "^1.4.0"
+ },
+ "funding": {
+ "url": "https://github.com/wojtekmaj/react-time-picker?sponsor=1"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -14602,10 +14876,13 @@
}
},
"node_modules/scheduler": {
- "version": "0.25.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
- "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
- "license": "MIT"
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
},
"node_modules/schema-utils": {
"version": "4.3.0",
@@ -14660,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",
@@ -16508,6 +16792,15 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/update-input-width": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/update-input-width/-/update-input-width-1.4.2.tgz",
+ "integrity": "sha512-/p0XLhrQQQ4bMWD7bL9duYObwYCO1qGr8R19xcMmoMSmXuQ7/1//veUnCObQ7/iW6E2pGS6rFkS4TfH4ur7e/g==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/wojtekmaj/update-input-width?sponsor=1"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -16527,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",
@@ -16632,6 +16931,15 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/watchpack": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
@@ -16887,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 ba83d74..1af976f 100755
--- a/package.json
+++ b/package.json
@@ -7,9 +7,13 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0",
- "react": "^19.0.0",
- "react-dom": "^19.0.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",
"react-scripts": "5.0.1",
+ "react-time-picker": "^7.0.0",
"web-vitals": "^2.1.4"
},
"scripts": {
diff --git a/public/images/heart.png b/public/images/heart.png
new file mode 100644
index 0000000..ab3a958
Binary files /dev/null and b/public/images/heart.png differ
diff --git a/public/images/kakao_login.svg b/public/images/kakao_login.svg
new file mode 100644
index 0000000..40762a0
--- /dev/null
+++ b/public/images/kakao_login.svg
@@ -0,0 +1,12 @@
+
diff --git a/public/images/memo_background.png b/public/images/memo_background.png
new file mode 100644
index 0000000..13dc555
Binary files /dev/null and b/public/images/memo_background.png differ
diff --git a/public/images/mic_icon.png b/public/images/mic_icon.png
new file mode 100644
index 0000000..eba5a2e
Binary files /dev/null and b/public/images/mic_icon.png differ
diff --git a/public/logo192.png b/public/logo192.png
deleted file mode 100755
index fc44b0a..0000000
Binary files a/public/logo192.png and /dev/null differ
diff --git a/public/logo512.png b/public/logo512.png
deleted file mode 100755
index a4e47a6..0000000
Binary files a/public/logo512.png and /dev/null differ
diff --git a/src/App.js b/src/App.js
index 3784575..9b02a3a 100755
--- a/src/App.js
+++ b/src/App.js
@@ -1,25 +1,33 @@
-import logo from './logo.svg';
-import './App.css';
+import React from "react";
+import KakaoRedirectPage from "./components/oauth/KakaoRedirectPage";
+import {BrowserRouter as Router, Routes, Route } from "react-router-dom";
+import LoginPage from "./pages/LoginPage";
+import SignupPage from "./pages/SignupPage";
+import VoiceTrainingPage from "./pages/VoiceTrainingPage";
+import KeywordSelectionPage from "./pages/KeywordSelectionPage";
+import FinalPage from "./pages/FinalPage";
+import HomePage from "./pages/HomePage";
+import ReportsPage from "./pages/ReportsPage";
+
+
function App() {
return (
-
+
+
+ }/>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* 카카오 로그인 리다이렉트 경로 추가 */}
+ } />
+
+
);
}
-export default App;
+export default App;
\ No newline at end of file
diff --git a/src/axios/TokenInterceptor.js b/src/axios/TokenInterceptor.js
new file mode 100644
index 0000000..65e38bb
--- /dev/null
+++ b/src/axios/TokenInterceptor.js
@@ -0,0 +1,88 @@
+import axios from 'axios';
+import { SPRING_API_URL } from "../constants/api";
+
+const instance = axios.create();
+
+instance.defaults.withCredentials = true;
+
+// 요청 인터셉터 : API call 하기 전에 실행
+instance.interceptors.request.use(function (config) {
+
+ // 로컬 스토리지에서 accessToken을 가져온다
+ const accessToken = localStorage.getItem('accessToken');
+ console.log(accessToken);
+
+ // accessToken이 있으면 요청 헤더에 추가한다.
+ if (accessToken)
+ config.headers['Authorization'] = `Bearer ${accessToken}`;
+ return config;
+
+}, function (error) {
+ // 요청 오류 처리
+ return Promise.reject(error);
+});
+
+// 응답 인터셉터 : 응답을 받고 then, catch 처리하기 전에 실행
+// 토큰 재인증, 자동 로그아웃 등 처리
+instance.interceptors.response.use(async function (response) {
+
+ // 응답 데이터 있는 작업 수행
+ // 2xx 범위에 있는 상태 코드인 경우
+ return response;
+}, async function (error) {
+
+ // 응답 오류가 있는 작업 수행
+ // 2xx 범위 밖에 있는 상태 코드인 경우
+
+ const {config, response} = error;
+ if (!response) {
+ console.error('Network or server error', error);
+ return Promise.reject(error);
+ }
+
+ // 백엔드단의 JWT Filter에서 걸리는 에러 처리
+ const {status, data} = response;
+ console.log(response);
+ if (status === 401) {
+ if (data.message === "토큰이 없습니다") {
+ await Logout();
+ }
+ if (data.message === "유효하지 않은 토큰") {
+ try {
+ const tokenReissueResult = await instance.post(`${ SPRING_API_URL}/reissue`);
+ if (tokenReissueResult.status === 200) {
+ // 재발급 성공시 로컬스토리지에 토큰 저장
+ const accessToken = tokenReissueResult.headers['authorization'] || tokenReissueResult.headers['Authorization'];
+ localStorage.setItem('accessToken', accessToken);
+ // 토큰 재발급 성공, API 재요청
+ console.log("토큰 재발급 성공");
+ return instance(config)
+ } else {
+ await Logout();
+ }
+ } catch (e) {
+ await Logout();
+ }
+ }
+ }
+
+ return Promise.reject(error);
+});
+
+
+export const Logout = async () => {
+ try {
+ const token = localStorage.getItem('accessToken');
+ await axios.post(`${ SPRING_API_URL}/logout`, null, {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ });
+ localStorage.removeItem('accessToken');
+ window.location.href = '/'; // 로그인 페이지로 이동
+ } catch (error) {
+ console.error('로그아웃 오류 발생:', error);
+ }
+};
+
+export default instance;
\ No newline at end of file
diff --git a/src/components/Button.js b/src/components/Button.js
new file mode 100644
index 0000000..9b9ae19
--- /dev/null
+++ b/src/components/Button.js
@@ -0,0 +1,27 @@
+import React from "react";
+
+const Button = ({ text, color, onClick, icon }) => {
+ return (
+
+ );
+};
+
+export default Button;
\ No newline at end of file
diff --git a/src/components/Header.js b/src/components/Header.js
new file mode 100644
index 0000000..d65a0c0
--- /dev/null
+++ b/src/components/Header.js
@@ -0,0 +1,9 @@
+// components/Header.js
+
+export const getAccessToken = () => {
+ const token = localStorage.getItem("accessToken");
+ if (!token) {
+ console.warn("accessToken이 없습니다.");
+ }
+ return token;
+};
diff --git a/src/components/Logo.js b/src/components/Logo.js
new file mode 100644
index 0000000..9b562aa
--- /dev/null
+++ b/src/components/Logo.js
@@ -0,0 +1,11 @@
+import React from "react";
+
+const Logo = () => {
+ return (
+
+

+
+ );
+};
+
+export default Logo;
\ No newline at end of file
diff --git a/src/components/MicButton.js b/src/components/MicButton.js
new file mode 100644
index 0000000..045ac06
--- /dev/null
+++ b/src/components/MicButton.js
@@ -0,0 +1,73 @@
+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);
+ });
+ };
+
+ return (
+
+ );
+};
+
+const styles = {
+ micButton: {
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ marginBottom: "20px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center"
+ },
+ micIcon: {
+ width: "60px",
+ height: "60px",
+ },
+};
+
+export default MicButton;
\ No newline at end of file
diff --git a/src/components/oauth/KakaoRedirectPage.js b/src/components/oauth/KakaoRedirectPage.js
new file mode 100644
index 0000000..78ec41d
--- /dev/null
+++ b/src/components/oauth/KakaoRedirectPage.js
@@ -0,0 +1,52 @@
+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";
+
+
+const KakaoRedirectPage = () => {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const isCalled = useRef(false); // 한 번만 실행되도록 플래그
+
+ useEffect(() => {
+ const handleOAuthKakao = async (code) => {
+ try {
+ const response = await instance.get(
+ `${ SPRING_API_URL}/oauth/login/kakao?code=${code}`
+ );
+
+ if (response.data.isSuccess) {
+ const accessToken =
+ response.headers["Authorization"] || response.headers["authorization"];
+ localStorage.setItem("accessToken", accessToken);
+
+ const role = response.data.result;
+ if (role === "FIRST") {
+ navigate("/voice-training");
+ } else {
+ navigate("/home");
+ }
+ } else {
+ console.error("OAuth2 로그인 오류");
+ console.log(response.data.code);
+ console.log(response.data.message);
+ }
+ } catch (error) {
+ console.error("로그인 실패", error);
+ }
+ };
+
+ const searchParams = new URLSearchParams(location.search);
+ const code = searchParams.get("code");
+
+ if (code && !isCalled.current) {
+ isCalled.current = true; // ✅ 한 번만 호출되도록 설정
+ handleOAuthKakao(code);
+ }
+ }, [location, navigate]);
+
+ return ;
+};
+
+export default KakaoRedirectPage;
diff --git a/src/constants/api.js b/src/constants/api.js
new file mode 100644
index 0000000..7e3dff9
--- /dev/null
+++ b/src/constants/api.js
@@ -0,0 +1,5 @@
+export const LOCAL_SPRING_API_URL = "http://localhost:8080/api/spring";
+export const LOCAL_FASTAPI_API_URL = "http://localhost:8000/api/fastapi";
+
+export const SPRING_API_URL = "https://www.humanicare.store/api/spring";
+export const FASTAPI_API_URL = "https://www.humanicare.store/api/fastapi";
\ No newline at end of file
diff --git a/src/constants/recordScript.js b/src/constants/recordScript.js
new file mode 100644
index 0000000..3d8d2c7
--- /dev/null
+++ b/src/constants/recordScript.js
@@ -0,0 +1,8 @@
+// 사용자 로그인 시에 목소리 녹음 대본
+
+
+const recordScript = `
+ 부모님께서는 우리가 세상에서 가장 소중한 존재입니다. 그들의 사랑과 희생에 감사하며, 작은 일부터 시작해 효도하는 마음을 잊지 말고 살아가길 바랍니다. 언제나 부모님께 감사한 마음을 표현하세요.
+`;
+
+export default recordScript;
\ No newline at end of file
diff --git a/src/global.css b/src/global.css
new file mode 100644
index 0000000..c3e4b88
--- /dev/null
+++ b/src/global.css
@@ -0,0 +1,54 @@
+/* 기본 설정 */
+* {
+ box-sizing: border-box;
+ }
+
+ body {
+ margin: 0;
+ padding: 0;
+ background-color: var(--bg-color);
+ font-family: "Noto Sans KR", sans-serif;
+ }
+
+ /* CSS 변수 정의 */
+ :root {
+ --bg-color: #f8ead2;
+ --accent-color: #dabec9;
+ --font-dark: #1c2c5b;
+ --font-light: #999;
+ --btn-radius: 12px;
+ }
+
+ /* 버튼 스타일 */
+ .button-basic {
+ padding: 12px 24px;
+ border: 1px solid #333;
+ background-color: white;
+ border-radius: var(--btn-radius);
+ font-size: 16px;
+ cursor: pointer;
+ }
+
+ .button-accent {
+ background-color: var(--accent-color);
+ color: white;
+ border: none;
+ }
+
+ /* 센터 정렬 */
+ .flex-center {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ /* 카드/박스 */
+ .card {
+ background-color: white;
+ border: 1px solid #ccc;
+ border-radius: var(--btn-radius);
+ padding: 20px;
+ box-shadow: 2px 2px 6px rgba(0,0,0,0.1);
+ }
+
+
\ No newline at end of file
diff --git a/src/index.js b/src/index.js
index d563c0f..5aa89e3 100755
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
+import './global.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
diff --git a/src/pages/FinalPage.js b/src/pages/FinalPage.js
new file mode 100644
index 0000000..5d75a1e
--- /dev/null
+++ b/src/pages/FinalPage.js
@@ -0,0 +1,108 @@
+// FinalPage.jsx
+import React, { useState } from 'react';
+import Logo from "../components/Logo";
+import { Logout } from "../axios/TokenInterceptor"; // 실제 axios 파일 경로에 맞게 수정
+
+const FinalPage = () => {
+ const [showConfirm, setShowConfirm] = useState(false);
+
+ const handleLogoutClick = () => {
+ setShowConfirm(true);
+ };
+
+ const handleConfirmYes = async () => {
+ setShowConfirm(false);
+ await Logout(); // 로그아웃 처리 및 이동
+ };
+
+ const handleConfirmNo = () => {
+ setShowConfirm(false);
+ };
+
+ return (
+
+
+
완료되었습니다.
+
+
+
+ {showConfirm && (
+
+
+
진짜 로그아웃 하시겠습니까?
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+
+const styles = {
+ container: {
+ backgroundColor: '#F4E6CE',
+ height: '100vh',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ position: 'relative',
+ },
+ text: {
+ fontSize: 20,
+ fontWeight: 'bold',
+ marginBottom: 40,
+ },
+ mainButton: {
+ padding: '10px 24px',
+ border: '1px solid black',
+ borderRadius: '16px',
+ backgroundColor: '#F4E6CE',
+ marginBottom: 20,
+ cursor: 'default',
+ },
+ logoutButton: {
+ padding: '10px 24px',
+ border: '1px solid black',
+ borderRadius: '16px',
+ backgroundColor: '#fff',
+ color: '#000',
+ cursor: 'pointer',
+ },
+ popupOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ popupBox: {
+ backgroundColor: '#fff',
+ padding: 24,
+ borderRadius: 12,
+ boxShadow: '0 4px 8px rgba(0,0,0,0.2)',
+ textAlign: 'center',
+ },
+ popupButtons: {
+ display: 'flex',
+ justifyContent: 'space-around',
+ marginTop: 16,
+ },
+ popupBtn: {
+ padding: '8px 16px',
+ borderRadius: 8,
+ border: '1px solid #000',
+ backgroundColor: '#F4E6CE',
+ cursor: 'pointer',
+ },
+};
+
+export default FinalPage;
diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js
new file mode 100644
index 0000000..bf92db7
--- /dev/null
+++ b/src/pages/HomePage.js
@@ -0,0 +1,56 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import Logo from "../components/Logo"; // 올바른 경로
+
+const HomePage = () => {
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = {
+ container: {
+ backgroundColor: "#F8EAD2",
+ height: "100vh",
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "center", // 수직 가운데
+ alignItems: "center", // 수평 가운데
+ padding: "20px",
+ },
+ logoWrapper: {
+ marginBottom: "40px",
+ },
+ buttonContainer: {
+ display: "flex",
+ gap: "30px",
+ flexDirection: "row",
+ justifyContent: "center",
+ },
+ button: {
+ width: "140px",
+ height: "100px",
+ fontSize: "16px",
+ border: "1px solid #555",
+ borderRadius: "20px",
+ backgroundColor: "#FFF",
+ cursor: "pointer",
+ boxShadow: "2px 2px 5px rgba(0,0,0,0.2)",
+ },
+};
+
+export default HomePage;
\ No newline at end of file
diff --git a/src/pages/KeywordSelectionPage.js b/src/pages/KeywordSelectionPage.js
new file mode 100644
index 0000000..374df95
--- /dev/null
+++ b/src/pages/KeywordSelectionPage.js
@@ -0,0 +1,403 @@
+import React, { useState, useEffect } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import TimePicker from "react-time-picker";
+import "react-time-picker/dist/TimePicker.css";
+import "react-clock/dist/Clock.css";
+import Logo from "../components/Logo";
+import axios from "axios";
+import { SPRING_API_URL } from "../constants/api";
+import { getAccessToken } from "../components/Header";
+
+const keywords = {
+ "수면 여부 확인": ["아침", "밤"],
+ "식사 여부 확인": ["아침", "점심", "저녁"],
+ "약 복용 여부 확인": ["아침", "점심", "저녁"],
+ "활동 여부 확인": ["외출", "청소", "교회", "운동", "목욕"]
+};
+
+const timeSettingKeywords = new Set([
+ "수면 여부 확인", "식사 여부 확인", "약 복용 여부 확인", "활동 여부 확인"
+]);
+
+const weekdays = ["월", "화", "수", "목", "금", "토", "일"];
+const dayMap = {
+ "월": "MONDAY", "화": "TUESDAY", "수": "WEDNESDAY",
+ "목": "THURSDAY", "금": "FRIDAY", "토": "SATURDAY", "일": "SUNDAY"
+};
+const reverseDayMap = Object.fromEntries(
+ Object.entries(dayMap).map(([k, v]) => [v, k])
+);
+
+const TimeSetting = ({ value, onSave }) => {
+ const [time, setTime] = useState(value.time);
+ const [days, setDays] = useState(value.days || []);
+
+ const toggleDay = (day) => {
+ const newDays = days.includes(day)
+ ? days.filter(d => d !== day)
+ : [...days, day];
+ setDays(newDays);
+ onSave({ time, days: newDays });
+ };
+
+ const handleTimeChange = (newTime) => {
+ setTime(newTime);
+ onSave({ time: newTime, days });
+ };
+
+ return (
+
+
+ {weekdays.map((day) => (
+
+ ))}
+
+
+
+ );
+};
+
+const KeywordOption = ({ category, keyword, data, onToggle, onSave }) => (
+
+
+ {data.selected && timeSettingKeywords.has(category) && (
+
+ )}
+
+);
+
+const KeywordSelectionPage = () => {
+ const navigate = useNavigate();
+ const { id } = useParams();
+
+ const [selected, setSelected] = useState(
+ Object.fromEntries(
+ Object.entries(keywords).map(([category, list]) => [
+ category,
+ Object.fromEntries(
+ list.map((keyword) => [
+ keyword,
+ { selected: false, time: "08:00", days: [] }
+ ])
+ )
+ ])
+ )
+ );
+
+ const [guardianTitle, setGuardianTitle] = useState("");
+ const [guardianPhone, setGuardianPhone] = useState("");
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const token = getAccessToken();
+ if (!token) return;
+
+ try {
+ const response = await axios.get(
+ `${SPRING_API_URL}/all-basic-schedules`,
+ {
+ headers: { Authorization: `Bearer ${token}` },
+ }
+ );
+ const schedules = response.data.result;
+
+ 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("_");
+ const category = Object.keys(keywords).find(
+ c => c.replace(/\s/g, "") === categoryPrefix
+ );
+ if (!category || !keywords[category].includes(keyword)) return;
+
+ const time = item.startTime.slice(0, 5);
+ const days = item.days.map(d => reverseDayMap[d]).filter(Boolean);
+
+ updatedSelected[category][keyword] = {
+ selected: true,
+ time,
+ days
+ };
+ });
+
+ setSelected(updatedSelected);
+ } catch (error) {
+ console.error("기존 키워드 데이터를 불러오는 중 오류 발생:", error);
+ }
+ };
+
+ fetchData();
+ }, [id]);
+
+ const toggleSelection = (category, keyword) => {
+ setSelected(prev => ({
+ ...prev,
+ [category]: {
+ ...prev[category],
+ [keyword]: {
+ ...prev[category][keyword],
+ selected: !prev[category][keyword].selected
+ }
+ }
+ }));
+ };
+
+ const saveTimeAndDays = (category, keyword, newData) => {
+ setSelected(prev => ({
+ ...prev,
+ [category]: {
+ ...prev[category],
+ [keyword]: { ...prev[category][keyword], ...newData }
+ }
+ }));
+ };
+
+ const handleComplete = async () => {
+ const token = getAccessToken();
+ if (!token) {
+ alert("로그인이 필요합니다.");
+ return;
+ }
+
+ const payload = [];
+ for (const [category, options] of Object.entries(selected)) {
+ for (const [keyword, { selected, time, days }] of Object.entries(options)) {
+ if (selected && days.length > 0) {
+ payload.push({
+ scheduleTitle: `${category.replace(/\s/g, "")}_${keyword}`,
+ startTime: time + ":00",
+ days: days.map(d => dayMap[d])
+ });
+ }
+ }
+ }
+
+ if (guardianTitle.trim() !== "") {
+ payload.push({
+ scheduleTitle : "GuardianTitle_" + guardianTitle.trim(),
+ startTime: "00:00:00",
+ days: []
+ });
+ }
+
+ if (guardianPhone.trim() !== "") {
+ payload.push({
+ scheduleTitle : "GuardianPhone_" + guardianPhone.trim(),
+ startTime: "00:00:00",
+ days: []
+ });
+ }
+
+ try {
+ const response = await axios.post(
+ `${ SPRING_API_URL}/basic-schedules`,
+ payload,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+ navigate("/final");
+ } catch (error) {
+ console.error("데이터 전송 실패:", error);
+ alert("데이터 전송 중 오류가 발생했습니다.");
+ }
+ };
+
+ return (
+
+
+
확인하고 싶은 키워드를 선택해주세요.
+
+
+
+
+
+ setGuardianTitle(e.target.value)}
+ />
+
+
+
+
+ setGuardianPhone(e.target.value)}
+ />
+
+
+ {Object.entries(keywords).map(([category, options]) => (
+
+
• {category}
+
+ {options.map((keyword) => (
+ toggleSelection(category, keyword)}
+ onSave={(data) => saveTimeAndDays(category, keyword, data)}
+ />
+ ))}
+
+
+ ))}
+
+
+
+
+ );
+};
+
+const styles = {
+ container: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ backgroundColor: "#F8EAD2",
+ height: "100vh",
+ padding: "20px",
+ overflowY: "auto",
+ },
+ title: {
+ fontSize: "18px",
+ fontWeight: "bold",
+ marginBottom: "20px",
+ },
+ inputGroup: {
+ width: "100%",
+ maxWidth: "400px",
+ marginBottom: "15px",
+ },
+ label: {
+ display: "block",
+ marginBottom : "5px",
+ fontWeight: "bold",
+ },
+ input: {
+ width: "100%",
+ padding: "10px",
+ borderRadius: "10px",
+ border: "1px solid #ccc",
+ boxSizing: "border-box",
+ },
+ category: {
+ marginBottom: "15px",
+ textAlign: "left",
+ width: "100%",
+ },
+ categoryTitle: {
+ fontSize: "16px",
+ fontWeight: "bold",
+ marginBottom: "5px",
+ },
+ buttonContainer: {
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "10px",
+ },
+ keywordBlock: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "5px",
+ },
+ keywordButton: {
+ border: "2px solid black",
+ borderRadius: "20px",
+ padding: "10px 15px",
+ cursor: "pointer",
+ fontSize: "14px",
+ },
+ completeButton: {
+ backgroundColor: "#DABEC9",
+ border: "none",
+ padding: "10px 20px",
+ borderRadius: "8px",
+ fontSize: "16px",
+ cursor: "pointer",
+ marginTop: "20px",
+ },
+ timeSettingContainer: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "10px",
+ backgroundColor: "#FFF",
+ padding: "10px",
+ borderRadius: "10px",
+ border: "1px solid #ccc",
+ marginTop: "5px",
+ },
+ weekdayContainer: {
+ display: "flex",
+ gap: "6px",
+ flexWrap: "wrap",
+ justifyContent: "center",
+ },
+ dayButton: {
+ padding: "5px 8px",
+ borderRadius: "6px",
+ border: "1px solid #888",
+ cursor: "pointer",
+ fontSize: "12px",
+ },
+ contentWrapper: {
+ display: "flex",
+ justifyContent: "flex-start",
+ width: "100%",
+ maxWidth: "800px",
+ },
+ leftColumn: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-start",
+ width: "100%",
+ },
+};
+
+export default KeywordSelectionPage;
\ No newline at end of file
diff --git a/src/pages/LoginPage.js b/src/pages/LoginPage.js
new file mode 100644
index 0000000..ae28ac6
--- /dev/null
+++ b/src/pages/LoginPage.js
@@ -0,0 +1,51 @@
+import React from "react";
+import { SPRING_API_URL } from "../constants/api";
+import Logo from "../components/Logo";
+
+const LoginPage = () => {
+ const handleKakaoLogin = () => {
+ window.location.href = `${SPRING_API_URL}/oauth/kakao`;
+ };
+
+ return (
+
+
+
+ 혼자 계신 부모님이 걱정되시나요?
+ 휴머니케어로 함께하세요.
+
+

+
+ );
+};
+
+const styles = {
+ container: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100vh",
+ backgroundColor: "#FAE8D4",
+ padding: "0 20px",
+ textAlign: "center",
+ },
+ message: {
+ fontSize: "18px",
+ color: "#333",
+ margin: "30px 0 40px",
+ lineHeight: "1.5",
+ fontWeight: "500",
+ },
+ kakaoButton: {
+ width: "250px",
+ cursor: "pointer",
+ },
+};
+
+export default LoginPage;
\ No newline at end of file
diff --git a/src/pages/ReportsPage.js b/src/pages/ReportsPage.js
new file mode 100644
index 0000000..2f1f916
--- /dev/null
+++ b/src/pages/ReportsPage.js
@@ -0,0 +1,80 @@
+import React from "react";
+import Logo from "../components/Logo";
+
+const ReportsPage = () => {
+ return (
+
+
+
+
몇월 몇주차 리포트입니다.
+
+
+
+
+
+
감정 분석 결과 이미지가 여기에 표시됩니다.
+
+
+ );
+};
+
+const styles = {
+ container: {
+ backgroundColor: "#F8EAD2",
+ minHeight: "100vh",
+ padding: "30px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ },
+ header: {
+ width: "100%",
+ maxWidth: "500px",
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: "20px",
+ },
+ title: {
+ fontSize: "18px",
+ fontWeight: "bold",
+ color: "#1C2C5B",
+ },
+ reportBox: {
+ width: "100%",
+ maxWidth: "500px",
+ backgroundColor: "#F8EAD2",
+ border: "1px solid #888",
+ borderRadius: "12px",
+ padding: "20px",
+ marginBottom: "30px",
+ maxHeight: "250px",
+ overflowY: "auto",
+ boxSizing: "border-box",
+ },
+ reportText: {
+ fontSize: "15px",
+ color: "#444",
+ },
+ imagePlaceholder: {
+ width: "100%",
+ maxWidth: "500px",
+ height: "300px",
+ backgroundColor: "#FFF",
+ border: "1px solid #ccc",
+ borderRadius: "10px",
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ placeholderText: {
+ color: "#999",
+ fontStyle: "italic",
+ },
+};
+
+export default ReportsPage;
\ No newline at end of file
diff --git a/src/pages/SignupPage.js b/src/pages/SignupPage.js
new file mode 100644
index 0000000..5d43afd
--- /dev/null
+++ b/src/pages/SignupPage.js
@@ -0,0 +1,145 @@
+import React, { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import Button from "../components/Button";
+import Logo from "../components/Logo";
+
+const SignupPage = () => {
+ const navigate = useNavigate();
+
+ const [guardianName, setGuardianName] = useState("");
+ const [guardianBirth, setGuardianBirth] = useState("");
+ const [patientName, setPatientName] = useState("");
+ const [patientBirth, setPatientBirth] = useState("");
+ const [showPopup, setShowPopup] = useState(false); // 팝업 상태
+
+ const handleSubmit = () => {
+ if (!guardianName || !guardianBirth || !patientName || !patientBirth) {
+ setShowPopup(true);
+ return;
+ }
+
+ navigate("/voice-training");
+ };
+
+ return (
+
+
+
회원 정보 입력
+
+
+
+ setGuardianName(e.target.value)}
+ />
+
+
+
+
+ setGuardianBirth(e.target.value)}
+ />
+
+
+
+
+ setPatientName(e.target.value)}
+ />
+
+
+
+
+ setPatientBirth(e.target.value)}
+ />
+
+
+
+
+ {/* 팝업 표시 */}
+ {showPopup && (
+
+
+
모든 정보를 입력해 주세요.
+
+
+
+ )}
+
+ );
+};
+
+const styles = {
+ container: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100vh",
+ backgroundColor: "#F8EAD2",
+ padding: "0 20px",
+ },
+ title: {
+ fontSize: "24px",
+ marginBottom: "20px",
+ },
+ inputGroup: {
+ width: "100%",
+ maxWidth: "400px",
+ marginBottom: "15px",
+ },
+ label: {
+ display: "block",
+ marginBottom: "5px",
+ fontWeight: "bold",
+ },
+ input: {
+ width: "100%",
+ padding: "10px",
+ borderRadius: "10px",
+ border: "1px solid #ccc",
+ boxSizing: "border-box",
+ },
+ popupOverlay: {
+ position: "fixed",
+ top: 0,
+ left: 0,
+ width: "100vw",
+ height: "100vh",
+ backgroundColor: "rgba(0, 0, 0, 0.3)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: 999,
+ },
+ popup: {
+ backgroundColor: "#fff",
+ padding: "30px 40px",
+ borderRadius: "12px",
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
+ textAlign: "center",
+ },
+ popupButton: {
+ padding: "8px 16px",
+ borderRadius: "8px",
+ border: "none",
+ backgroundColor: "#DABEC9",
+ cursor: "pointer",
+ },
+};
+
+export default SignupPage;
diff --git a/src/pages/VoiceTrainingPage.js b/src/pages/VoiceTrainingPage.js
new file mode 100644
index 0000000..5ca0dcc
--- /dev/null
+++ b/src/pages/VoiceTrainingPage.js
@@ -0,0 +1,197 @@
+import React, { useState, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+import MicRecorder from "mic-recorder-to-mp3";
+import Logo from "../components/Logo";
+import { FASTAPI_API_URL } from "../constants/api";
+import {getAccessToken} from "../components/Header";
+
+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 token = getAccessToken();
+ if (!token) return;
+
+ const formData = new FormData();
+ formData.append("file", audioFile);
+
+ try {
+ const res = await fetch(`${FASTAPI_API_URL}/voices`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`
+ },
+ body: formData,
+ });
+ const data = await res.json();
+ console.log("업로드 성공:", data);
+ alert("업로드 완료!");
+ } catch (error) {
+ console.error("업로드 실패:", error);
+ alert("업로드 실패");
+ }
+ };
+
+ const handleReset = () => {
+ setBlobURL("");
+ setAudioFile(null);
+ };
+
+ return (
+
+
+
목소리를 학습하겠습니다.
+
(아래의 마이크 버튼을 누르고 텍스트를 읽어주세요.)
+
+
+
+
오늘 하루는 어땠나요? 기분이 괜찮으신가요?
+
밖에 나가서 산책도 하셨어요?
+
식사는 잘 챙기셨는지 궁금합니다.
+
약은 꼭 챙겨드셔야 해요. 잊지 마세요.
+
목소리를 들으니 안심이 됩니다.
+
+
+
+
+
+ {blobURL && (
+
+ )}
+
+
+
+ );
+};
+
+const styles = {
+ container: {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent : "center",
+ minHeight : "100vh",
+ backgroundColor : "#F8EAD2",
+ },
+ title: {
+ fontSize: "20px",
+ fontWeight: "bold",
+ },
+ subtitle: {
+ fontSize: "14px",
+ color: "#555",
+ marginBottom: "20px",
+ },
+ memoContainer: {
+ position: "relative",
+ width: "300px",
+ padding: "20px",
+ backgroundImage: "url('/images/memo_background.png')",
+ backgroundSize: "cover",
+ backgroundRepeat: "no-repeat",
+ marginBottom: "20px",
+ borderRadius: "10px",
+ },
+ memoText: {
+ fontSize: "14px",
+ lineHeight: "1.6",
+ fontWeight: "bold",
+ textAlign: "left",
+ },
+ micButton: {
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ marginBottom: "20px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center"
+ },
+ micIcon: {
+ width: "60px",
+ height: "60px",
+ },
+ audioControls: {
+ marginBottom: "20px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "10px"
+ },
+ controlButtons: {
+ display: "flex",
+ gap: "10px",
+ },
+ uploadButton: {
+ backgroundColor: "#A0D468",
+ border: "none",
+ padding: "8px 12px",
+ borderRadius: "6px",
+ cursor: "pointer"
+ },
+ resetButton: {
+ backgroundColor: "#ED5565",
+ border: "none",
+ padding: "8px 12px",
+ borderRadius: "6px",
+ cursor: "pointer"
+ },
+ nextButton: {
+ backgroundColor : "#DABEC9",
+ border: "none",
+ padding: "10px 20px",
+ borderRadius: "8px",
+ fontSize: "16px",
+ cursor: "pointer",
+ },
+};
+
+export default VoiceTrainingPage;
\ No newline at end of file