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

- Edit src/App.js and save to reload. -

- - Learn React - -
-
+ + + }/> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* 카카오 로그인 리다이렉트 경로 추가 */} + } /> + + ); } -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 ( +
+ Heart Logo +
+ ); +}; + +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)} + /> +
+ + +
+ + )} + + ); +}; + +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