ver.1
This commit is contained in:
commit
363c4abdc7
42 files changed
+6105
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.gemini
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# Stage 1: Build the React application
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package.json and package-lock.json
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy the rest of the application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve the application with Nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy the custom nginx configuration file
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy the built files from the previous stage
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start Nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
|
||||||
|
|
||||||
|
Note: This will impact Vite dev & build performances.
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: password
|
||||||
|
MARIADB_DATABASE: nutrition
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
- ./server/init.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: password
|
||||||
|
DB_NAME: nutrition
|
||||||
|
PORT: 3000
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>nutrition-management</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:3000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3680
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "nutrition-management",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"react-router-dom": "^7.17.0",
|
||||||
|
"recharts": "^3.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.29.0",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
|
"@types/babel__core": "^7.20.5",
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.59.2",
|
||||||
|
"vite": "^8.0.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "start"]
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const dbConfig = {
|
||||||
|
host: process.env.DB_HOST || 'db',
|
||||||
|
user: process.env.DB_USER || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || 'password',
|
||||||
|
database: process.env.DB_NAME || 'nutrition',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
// Wait for DB to be ready
|
||||||
|
const connectDB = async () => {
|
||||||
|
let retries = 5;
|
||||||
|
while (retries) {
|
||||||
|
try {
|
||||||
|
pool = mysql.createPool(dbConfig);
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
console.log('Connected to MariaDB');
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to connect to MariaDB, retrying...', err);
|
||||||
|
retries -= 1;
|
||||||
|
await new Promise(res => setTimeout(res, 5000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
connectDB();
|
||||||
|
|
||||||
|
// Get Profile
|
||||||
|
app.get('/api/profile/:userId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.userId]);
|
||||||
|
if (rows.length > 0) {
|
||||||
|
res.json(rows[0]);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Profile not found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save Profile
|
||||||
|
app.post('/api/profile', async (req, res) => {
|
||||||
|
const p = req.body;
|
||||||
|
const userId = p.id || 'default_user';
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO users (id, height, weight, age, gender, activityLevel, goal, bmi, tdee, targetCalories, targetProtein, targetFat, targetCarbs, targetWater)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
height=?, weight=?, age=?, gender=?, activityLevel=?, goal=?, bmi=?, tdee=?, targetCalories=?, targetProtein=?, targetFat=?, targetCarbs=?, targetWater=?
|
||||||
|
`, [
|
||||||
|
userId, p.height, p.weight, p.age, p.gender, p.activityLevel, p.goal, p.bmi, p.tdee, p.targetCalories, p.targetProtein, p.targetFat, p.targetCarbs, p.targetWater,
|
||||||
|
p.height, p.weight, p.age, p.gender, p.activityLevel, p.goal, p.bmi, p.tdee, p.targetCalories, p.targetProtein, p.targetFat, p.targetCarbs, p.targetWater
|
||||||
|
]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Logs (returns a mapped object { "YYYY-MM-DD": DailyLog })
|
||||||
|
app.get('/api/logs/:userId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [logRows] = await pool.query('SELECT * FROM daily_logs WHERE user_id = ?', [req.params.userId]);
|
||||||
|
const [mealRows] = await pool.query('SELECT * FROM meals WHERE user_id = ?', [req.params.userId]);
|
||||||
|
|
||||||
|
const logs = {};
|
||||||
|
|
||||||
|
logRows.forEach(row => {
|
||||||
|
// row.date is Date object, format to YYYY-MM-DD
|
||||||
|
const dateStr = new Date(row.date).toISOString().split('T')[0];
|
||||||
|
logs[dateStr] = {
|
||||||
|
date: dateStr,
|
||||||
|
waterIntake: row.waterIntake,
|
||||||
|
weight: row.weight,
|
||||||
|
meals: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
mealRows.forEach(row => {
|
||||||
|
const dateStr = new Date(row.date).toISOString().split('T')[0];
|
||||||
|
if (!logs[dateStr]) {
|
||||||
|
logs[dateStr] = { date: dateStr, waterIntake: 0, meals: [] };
|
||||||
|
}
|
||||||
|
logs[dateStr].meals.push({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
time: row.time,
|
||||||
|
calories: row.calories,
|
||||||
|
protein: row.protein,
|
||||||
|
fat: row.fat,
|
||||||
|
carbs: row.carbs
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(logs);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Meal
|
||||||
|
app.post('/api/logs/meal', async (req, res) => {
|
||||||
|
const { userId, date, meal } = req.body;
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO meals (id, user_id, date, name, time, calories, protein, fat, carbs)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [meal.id, userId, date, meal.name, meal.time, meal.calories, meal.protein, meal.fat, meal.carbs]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Water
|
||||||
|
app.post('/api/logs/water', async (req, res) => {
|
||||||
|
const { userId, date, amount } = req.body;
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO daily_logs (date, user_id, waterIntake)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE waterIntake = waterIntake + ?
|
||||||
|
`, [date, userId, amount, amount]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log Weight
|
||||||
|
app.post('/api/logs/weight', async (req, res) => {
|
||||||
|
const { userId, date, weight } = req.body;
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO daily_logs (date, user_id, weight)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE weight = ?
|
||||||
|
`, [date, userId, weight, weight]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear Data
|
||||||
|
app.delete('/api/data/:userId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM users WHERE id = ?', [req.params.userId]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`API server running on port ${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
height FLOAT NOT NULL,
|
||||||
|
weight FLOAT NOT NULL,
|
||||||
|
age INT NOT NULL,
|
||||||
|
gender VARCHAR(10) NOT NULL,
|
||||||
|
activityLevel VARCHAR(20) NOT NULL,
|
||||||
|
goal VARCHAR(20) NOT NULL,
|
||||||
|
bmi FLOAT NOT NULL,
|
||||||
|
tdee INT NOT NULL,
|
||||||
|
targetCalories INT NOT NULL,
|
||||||
|
targetProtein INT NOT NULL,
|
||||||
|
targetFat INT NOT NULL,
|
||||||
|
targetCarbs INT NOT NULL,
|
||||||
|
targetWater INT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_logs (
|
||||||
|
date DATE NOT NULL,
|
||||||
|
user_id VARCHAR(50) NOT NULL,
|
||||||
|
waterIntake INT DEFAULT 0,
|
||||||
|
weight FLOAT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (date, user_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meals (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(50) NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
time VARCHAR(10) NOT NULL,
|
||||||
|
calories INT NOT NULL,
|
||||||
|
protein INT DEFAULT 0,
|
||||||
|
fat INT DEFAULT 0,
|
||||||
|
carbs INT DEFAULT 0,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "nutrition-management-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "API for Nutrition Management App",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"mysql2": "^3.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAppData } from './context/AppDataContext';
|
||||||
|
import BottomNavigation from './components/BottomNavigation';
|
||||||
|
import Onboarding from './pages/Onboarding';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import AddMeal from './pages/AddMeal';
|
||||||
|
import Charts from './pages/Charts';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
|
const AppRoutes = () => {
|
||||||
|
const { profile } = useAppData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile && location.pathname !== '/onboarding') {
|
||||||
|
navigate('/onboarding');
|
||||||
|
} else if (profile && location.pathname === '/onboarding') {
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else if (profile && location.pathname === '/') {
|
||||||
|
navigate('/dashboard');
|
||||||
|
}
|
||||||
|
}, [profile, location, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/onboarding" element={<Onboarding />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/add-meal" element={<AddMeal />} />
|
||||||
|
<Route path="/charts" element={<Charts />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
|
||||||
|
{profile && location.pathname !== '/onboarding' && (
|
||||||
|
<BottomNavigation />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AppRoutes />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 70px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item span {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-fab {
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: -24px;
|
||||||
|
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
|
||||||
|
border: 4px solid var(--bg-primary);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active .nav-fab {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.6);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { LayoutDashboard, PlusCircle, PieChart, Settings } from 'lucide-react';
|
||||||
|
import './BottomNavigation.css';
|
||||||
|
|
||||||
|
const BottomNavigation: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<nav className="bottom-nav">
|
||||||
|
<NavLink to="/dashboard" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<LayoutDashboard size={24} />
|
||||||
|
<span>主頁</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/add-meal" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<div className="nav-fab">
|
||||||
|
<PlusCircle size={28} />
|
||||||
|
</div>
|
||||||
|
<span>紀錄</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/charts" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<PieChart size={24} />
|
||||||
|
<span>分析</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/settings" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
|
||||||
|
<Settings size={24} />
|
||||||
|
<span>設定</span>
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BottomNavigation;
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { UserProfile, DailyLog, Meal } from '../types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface AppDataContextType {
|
||||||
|
profile: UserProfile | null;
|
||||||
|
saveProfile: (profile: UserProfile) => void;
|
||||||
|
logs: Record<string, DailyLog>; // Key is YYYY-MM-DD
|
||||||
|
getTodayLog: () => DailyLog;
|
||||||
|
addMeal: (meal: Omit<Meal, 'id'>) => void;
|
||||||
|
addWater: (amount: number) => void;
|
||||||
|
logWeight: (weight: number) => void;
|
||||||
|
clearData: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppDataContext = createContext<AppDataContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const USER_ID = 'default_user'; // Single user for now
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
export const AppDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [logs, setLogs] = useState<Record<string, DailyLog>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load data from DB on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const profileRes = await fetch(`${API_BASE}/profile/${USER_ID}`);
|
||||||
|
if (profileRes.ok) {
|
||||||
|
const profileData = await profileRes.json();
|
||||||
|
setProfile(profileData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logsRes = await fetch(`${API_BASE}/logs/${USER_ID}`);
|
||||||
|
if (logsRes.ok) {
|
||||||
|
const logsData = await logsRes.json();
|
||||||
|
setLogs(logsData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch data', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveProfile = async (newProfile: UserProfile) => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/profile`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: USER_ID, ...newProfile })
|
||||||
|
});
|
||||||
|
setProfile(newProfile);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save profile', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTodayStr = () => format(new Date(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
const getTodayLog = (): DailyLog => {
|
||||||
|
const today = getTodayStr();
|
||||||
|
return logs[today] || { date: today, meals: [], waterIntake: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMeal = async (mealData: Omit<Meal, 'id'>) => {
|
||||||
|
const today = getTodayStr();
|
||||||
|
const newMeal: Meal = {
|
||||||
|
...mealData,
|
||||||
|
id: Date.now().toString()
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/logs/meal`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: USER_ID, date: today, meal: newMeal })
|
||||||
|
});
|
||||||
|
setLogs(prev => {
|
||||||
|
const todayLog = prev[today] || { date: today, meals: [], waterIntake: 0 };
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[today]: {
|
||||||
|
...todayLog,
|
||||||
|
meals: [...todayLog.meals, newMeal]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add meal', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addWater = async (amount: number) => {
|
||||||
|
const today = getTodayStr();
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/logs/water`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: USER_ID, date: today, amount })
|
||||||
|
});
|
||||||
|
setLogs(prev => {
|
||||||
|
const todayLog = prev[today] || { date: today, meals: [], waterIntake: 0 };
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[today]: {
|
||||||
|
...todayLog,
|
||||||
|
waterIntake: todayLog.waterIntake + amount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add water', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logWeight = async (weight: number) => {
|
||||||
|
const today = getTodayStr();
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/logs/weight`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: USER_ID, date: today, weight })
|
||||||
|
});
|
||||||
|
setLogs(prev => {
|
||||||
|
const todayLog = prev[today] || { date: today, meals: [], waterIntake: 0 };
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[today]: {
|
||||||
|
...todayLog,
|
||||||
|
weight
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to log weight', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearData = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/data/${USER_ID}`, { method: 'DELETE' });
|
||||||
|
setProfile(null);
|
||||||
|
setLogs({});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clear data', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div style={{height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center'}}>載入中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppDataContext.Provider value={{ profile, saveProfile, logs, getTodayLog, addMeal, addWater, logWeight, clearData, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</AppDataContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAppData = () => {
|
||||||
|
const context = useContext(AppDataContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAppData must be used within an AppDataProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Color Palette */
|
||||||
|
--bg-primary: #0f172a;
|
||||||
|
--bg-secondary: #1e293b;
|
||||||
|
--bg-tertiary: #334155;
|
||||||
|
|
||||||
|
--text-primary: #f8fafc;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
|
||||||
|
--accent-primary: #3b82f6; /* Blue for water / main */
|
||||||
|
--accent-secondary: #8b5cf6; /* Purple for aesthetic */
|
||||||
|
--accent-success: #10b981; /* Green for macros */
|
||||||
|
--accent-warning: #f59e0b; /* Orange */
|
||||||
|
--accent-danger: #ef4444; /* Red */
|
||||||
|
|
||||||
|
--macro-protein: #8b5cf6;
|
||||||
|
--macro-fat: #f59e0b;
|
||||||
|
--macro-carbs: #10b981;
|
||||||
|
--water-color: #3b82f6;
|
||||||
|
|
||||||
|
--border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
--glass-bg: rgba(30, 41, 59, 0.7);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
--font-family: 'Inter', sans-serif;
|
||||||
|
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px; /* Mobile App feel */
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.glass-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-between {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Elements */
|
||||||
|
input, select, button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Container */
|
||||||
|
.page-container {
|
||||||
|
padding: 24px;
|
||||||
|
padding-bottom: 90px; /* Space for bottom nav */
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1 { font-size: 28px; font-weight: 700; margin-bottom: 16px; }
|
||||||
|
h2 { font-size: 22px; font-weight: 600; margin-bottom: 12px; }
|
||||||
|
h3 { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
p { color: var(--text-secondary); margin-bottom: 12px; }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { AppDataProvider } from './context/AppDataContext';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppDataProvider>
|
||||||
|
<App />
|
||||||
|
</AppDataProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-input {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macros-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macros-inputs .form-group label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppData } from '../context/AppDataContext';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import './AddMeal.css';
|
||||||
|
|
||||||
|
const AddMeal: React.FC = () => {
|
||||||
|
const { addMeal } = useAppData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [time, setTime] = useState(format(new Date(), 'HH:mm'));
|
||||||
|
const [calories, setCalories] = useState('');
|
||||||
|
const [protein, setProtein] = useState('');
|
||||||
|
const [fat, setFat] = useState('');
|
||||||
|
const [carbs, setCarbs] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// basic validation
|
||||||
|
if (!calories) return;
|
||||||
|
|
||||||
|
addMeal({
|
||||||
|
name: name || '未命名餐點',
|
||||||
|
time,
|
||||||
|
calories: Number(calories),
|
||||||
|
protein: Number(protein) || 0,
|
||||||
|
fat: Number(fat) || 0,
|
||||||
|
carbs: Number(carbs) || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate('/dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="add-meal-container page-container animate-fade-in">
|
||||||
|
<header className="mb-4">
|
||||||
|
<h1>紀錄飲食</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="glass-card" onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>餐點名稱 (選填)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="例如:雞胸肉便當"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>時間</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
className="input-field"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group flex-1">
|
||||||
|
<label>熱量 (kcal)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field highlight-input"
|
||||||
|
placeholder="0"
|
||||||
|
value={calories}
|
||||||
|
onChange={(e) => setCalories(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mt-4 mb-2">三大營養素 (選填)</h3>
|
||||||
|
<div className="macros-inputs">
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{color: 'var(--macro-protein)'}}>蛋白質 (g)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="0"
|
||||||
|
value={protein}
|
||||||
|
onChange={(e) => setProtein(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{color: 'var(--macro-fat)'}}>脂肪 (g)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="0"
|
||||||
|
value={fat}
|
||||||
|
onChange={(e) => setFat(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label style={{color: 'var(--macro-carbs)'}}>碳水 (g)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
placeholder="0"
|
||||||
|
value={carbs}
|
||||||
|
onChange={(e) => setCarbs(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn-primary mt-4">儲存紀錄</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddMeal;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
.chart-wrapper {
|
||||||
|
height: 200px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-chart-wrapper {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item .dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAppData } from '../context/AppDataContext';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
|
PieChart, Pie, Cell, LineChart, Line
|
||||||
|
} from 'recharts';
|
||||||
|
import { format, subDays } from 'date-fns';
|
||||||
|
import './Charts.css';
|
||||||
|
|
||||||
|
const Charts: React.FC = () => {
|
||||||
|
const { profile, getTodayLog, logs } = useAppData();
|
||||||
|
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
const todayLog = getTodayLog();
|
||||||
|
const totalProtein = todayLog.meals.reduce((sum, m) => sum + m.protein, 0);
|
||||||
|
const totalFat = todayLog.meals.reduce((sum, m) => sum + m.fat, 0);
|
||||||
|
const totalCarbs = todayLog.meals.reduce((sum, m) => sum + m.carbs, 0);
|
||||||
|
|
||||||
|
const pieData = [
|
||||||
|
{ name: '蛋白質', value: totalProtein, color: '#8b5cf6' },
|
||||||
|
{ name: '脂肪', value: totalFat, color: '#f59e0b' },
|
||||||
|
{ name: '碳水', value: totalCarbs, color: '#10b981' },
|
||||||
|
].filter(item => item.value > 0);
|
||||||
|
|
||||||
|
// Generate 7 days trend data from actual logs
|
||||||
|
const trendData = Array.from({ length: 7 }).map((_, i) => {
|
||||||
|
const d = subDays(new Date(), 6 - i);
|
||||||
|
const dateStr = format(d, 'yyyy-MM-dd');
|
||||||
|
const displayDate = format(d, 'MM/dd');
|
||||||
|
const dayLog = logs[dateStr];
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: displayDate,
|
||||||
|
calories: dayLog ? dayLog.meals.reduce((sum, m) => sum + m.calories, 0) : 0,
|
||||||
|
weight: (dayLog && dayLog.weight) ? dayLog.weight : profile.weight
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="charts-container page-container animate-fade-in">
|
||||||
|
<header className="mb-4">
|
||||||
|
<h1>數據分析</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="glass-card mb-4">
|
||||||
|
<h2>近7日體重趨勢 (kg)</h2>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={trendData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="date" stroke="var(--text-muted)" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
|
<YAxis domain={['dataMin - 1', 'dataMax + 1']} stroke="var(--text-muted)" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: 'var(--text-primary)' }}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="weight" stroke="var(--accent-primary)" strokeWidth={3} dot={{ r: 4, fill: 'var(--accent-primary)' }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card mb-4">
|
||||||
|
<h2>近7日熱量攝取 (kcal)</h2>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={trendData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<XAxis dataKey="date" stroke="var(--text-muted)" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
|
<YAxis stroke="var(--text-muted)" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: '8px' }}
|
||||||
|
cursor={{ fill: 'rgba(255, 255, 255, 0.05)' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="calories" fill="var(--accent-secondary)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card mb-4">
|
||||||
|
<h2>今日營養比例</h2>
|
||||||
|
<div className="chart-wrapper pie-chart-wrapper">
|
||||||
|
{pieData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} stroke="transparent" />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--bg-secondary)', border: 'none', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: '#fff' }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex-center" style={{ height: '100%', color: 'var(--text-muted)' }}>
|
||||||
|
尚未有足夠數據
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pie-legend">
|
||||||
|
<div className="legend-item"><span className="dot" style={{backgroundColor: 'var(--macro-protein)'}}></span> 蛋白質</div>
|
||||||
|
<div className="legend-item"><span className="dot" style={{backgroundColor: 'var(--macro-fat)'}}></span> 脂肪</div>
|
||||||
|
<div className="legend-item"><span className="dot" style={{backgroundColor: 'var(--macro-carbs)'}}></span> 碳水</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Charts;
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-display {
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.diet-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Circular Progress */
|
||||||
|
.circular-progress {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring__circle {
|
||||||
|
transition: stroke-dashoffset 0.8s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circular-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calorie-display {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calorie-display .consumed {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calorie-display .target {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Linear Progress */
|
||||||
|
.macros-list {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-bg {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Water Card */
|
||||||
|
.water-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-header h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-stats {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--water-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-visual-container {
|
||||||
|
height: 120px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-level-bg {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-level-fill {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(180deg, #60a5fa 0%, #3b82f6 100%);
|
||||||
|
transition: height 0.8s ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: var(--water-color);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.water-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAppData } from '../context/AppDataContext';
|
||||||
|
import { Droplets, Plus, Scale } from 'lucide-react';
|
||||||
|
import './Dashboard.css';
|
||||||
|
|
||||||
|
const CircularProgress: React.FC<{ progress: number, size: number, strokeWidth: number, color: string, children: React.ReactNode }> = ({ progress, size, strokeWidth, color, children }) => {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = radius * 2 * Math.PI;
|
||||||
|
const offset = circumference - (Math.min(progress, 1) * circumference);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="circular-progress" style={{ width: size, height: size }}>
|
||||||
|
<svg className="progress-ring" width={size} height={size}>
|
||||||
|
<circle
|
||||||
|
className="progress-ring__circle-bg"
|
||||||
|
stroke="var(--bg-tertiary)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="transparent"
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="progress-ring__circle"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={`${circumference} ${circumference}`}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
fill="transparent"
|
||||||
|
r={radius}
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="circular-content">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LinearProgress: React.FC<{ label: string, value: number, target: number, color: string }> = ({ label, value, target, color }) => {
|
||||||
|
const percentage = Math.min((value / target) * 100, 100) || 0;
|
||||||
|
return (
|
||||||
|
<div className="linear-progress-container">
|
||||||
|
<div className="progress-header">
|
||||||
|
<span className="progress-label">{label}</span>
|
||||||
|
<span className="progress-values">{Math.round(value)} / {target}g</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar-bg">
|
||||||
|
<div className="progress-bar-fill" style={{ width: `${percentage}%`, backgroundColor: color }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const { profile, getTodayLog, addWater, logWeight } = useAppData();
|
||||||
|
const [weightInput, setWeightInput] = React.useState('');
|
||||||
|
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
const todayLog = getTodayLog();
|
||||||
|
|
||||||
|
const totalCalories = todayLog.meals.reduce((sum, m) => sum + m.calories, 0);
|
||||||
|
const totalProtein = todayLog.meals.reduce((sum, m) => sum + m.protein, 0);
|
||||||
|
const totalFat = todayLog.meals.reduce((sum, m) => sum + m.fat, 0);
|
||||||
|
const totalCarbs = todayLog.meals.reduce((sum, m) => sum + m.carbs, 0);
|
||||||
|
|
||||||
|
const waterProgress = Math.min((todayLog.waterIntake / profile.targetWater) * 100, 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-container page-container animate-fade-in">
|
||||||
|
<header className="dashboard-header">
|
||||||
|
<h1>今日概況</h1>
|
||||||
|
<p className="date-display">{todayLog.date}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="glass-card mb-4">
|
||||||
|
<h2>飲食進度</h2>
|
||||||
|
<div className="diet-summary">
|
||||||
|
<CircularProgress
|
||||||
|
progress={totalCalories / profile.targetCalories}
|
||||||
|
size={160}
|
||||||
|
strokeWidth={12}
|
||||||
|
color="var(--accent-primary)"
|
||||||
|
>
|
||||||
|
<div className="calorie-display">
|
||||||
|
<span className="consumed">{Math.round(totalCalories)}</span>
|
||||||
|
<span className="target">/ {profile.targetCalories} kcal</span>
|
||||||
|
</div>
|
||||||
|
</CircularProgress>
|
||||||
|
|
||||||
|
<div className="macros-list">
|
||||||
|
<LinearProgress label="蛋白質" value={totalProtein} target={profile.targetProtein} color="var(--macro-protein)" />
|
||||||
|
<LinearProgress label="脂肪" value={totalFat} target={profile.targetFat} color="var(--macro-fat)" />
|
||||||
|
<LinearProgress label="碳水" value={totalCarbs} target={profile.targetCarbs} color="var(--macro-carbs)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card mb-4 water-card">
|
||||||
|
<div className="water-header">
|
||||||
|
<h2><Droplets size={20} color="var(--water-color)" /> 飲水追蹤</h2>
|
||||||
|
<span className="water-stats">{todayLog.waterIntake} / {profile.targetWater} ml</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="water-visual-container">
|
||||||
|
<div className="water-level-bg">
|
||||||
|
<div className="water-level-fill" style={{ height: `${waterProgress}%` }}></div>
|
||||||
|
{/* Optional wave effect */}
|
||||||
|
<div className="wave"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="water-actions mt-4">
|
||||||
|
<button className="water-btn" onClick={() => addWater(250)}>
|
||||||
|
<Plus size={16} /> 250ml 杯子
|
||||||
|
</button>
|
||||||
|
<button className="water-btn" onClick={() => addWater(500)}>
|
||||||
|
<Plus size={16} /> 500ml 寶特瓶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card mb-4 weight-card">
|
||||||
|
<div className="water-header">
|
||||||
|
<h2><Scale size={20} color="var(--accent-secondary)" /> 今日體重紀錄</h2>
|
||||||
|
<span className="water-stats" style={{color: 'var(--accent-secondary)'}}>
|
||||||
|
{todayLog.weight ? `${todayLog.weight} kg` : '尚未紀錄'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="weight-input-group mt-4" style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
placeholder={todayLog.weight ? todayLog.weight.toString() : profile.weight.toString()}
|
||||||
|
value={weightInput}
|
||||||
|
onChange={(e) => setWeightInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
style={{ width: 'auto', flexShrink: 0 }}
|
||||||
|
onClick={() => {
|
||||||
|
if (weightInput) {
|
||||||
|
logWeight(Number(weightInput));
|
||||||
|
setWeightInput('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
紀錄
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
.onboarding-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 10vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-active {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
border: 1px solid var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 0 10px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-2 { margin-top: 8px; }
|
||||||
|
.mt-4 { margin-top: 16px; }
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card .value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macros-preview {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAppData } from '../context/AppDataContext';
|
||||||
|
import type { Gender, Goal, ActivityLevel } from '../types';
|
||||||
|
import { generateProfile, getBMICategory } from '../utils/calculations';
|
||||||
|
import './Onboarding.css';
|
||||||
|
|
||||||
|
const Onboarding: React.FC = () => {
|
||||||
|
const { saveProfile } = useAppData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [gender, setGender] = useState<Gender>('male');
|
||||||
|
const [age, setAge] = useState<string>('25');
|
||||||
|
const [height, setHeight] = useState<string>('175');
|
||||||
|
const [weight, setWeight] = useState<string>('70');
|
||||||
|
const [activity, setActivity] = useState<ActivityLevel>('moderate');
|
||||||
|
const [goal, setGoal] = useState<Goal>('maintain');
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step < 3) setStep(step + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
const profile = generateProfile(
|
||||||
|
Number(height),
|
||||||
|
Number(weight),
|
||||||
|
Number(age),
|
||||||
|
gender,
|
||||||
|
activity,
|
||||||
|
goal
|
||||||
|
);
|
||||||
|
saveProfile(profile);
|
||||||
|
navigate('/dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="onboarding-container page-container">
|
||||||
|
<div className="onboarding-header">
|
||||||
|
<h1 className="text-gradient">歡迎來到營養管家</h1>
|
||||||
|
<p>讓我們設定您的專屬健康目標 (步驟 {step}/3)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card animate-fade-in">
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="step-content">
|
||||||
|
<h2>基本資料</h2>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>性別</label>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
className={gender === 'male' ? 'btn-active' : 'btn-outline'}
|
||||||
|
onClick={() => setGender('male')}
|
||||||
|
>男生</button>
|
||||||
|
<button
|
||||||
|
className={gender === 'female' ? 'btn-active' : 'btn-outline'}
|
||||||
|
onClick={() => setGender('female')}
|
||||||
|
>女生</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>年齡</label>
|
||||||
|
<input type="number" className="input-field" value={age} onChange={(e) => setAge(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>身高 (cm)</label>
|
||||||
|
<input type="number" className="input-field" value={height} onChange={(e) => setHeight(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>體重 (kg)</label>
|
||||||
|
<input type="number" className="input-field" value={weight} onChange={(e) => setWeight(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary mt-4" onClick={handleNext}>下一步</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="step-content">
|
||||||
|
<h2>生活型態與目標</h2>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>日常活動量</label>
|
||||||
|
<select className="input-field" value={activity} onChange={(e) => setActivity(e.target.value as ActivityLevel)}>
|
||||||
|
<option value="sedentary">久坐 (辦公室/少運動)</option>
|
||||||
|
<option value="light">輕度活動 (每週運動1-3天)</option>
|
||||||
|
<option value="moderate">中度活動 (每週運動3-5天)</option>
|
||||||
|
<option value="active">高度活動 (每週運動6-7天)</option>
|
||||||
|
<option value="very_active">極度活動 (體力勞動/每天訓練2次)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>您的目標</label>
|
||||||
|
<div className="btn-group vertical">
|
||||||
|
<button
|
||||||
|
className={goal === 'lose' ? 'btn-active' : 'btn-outline'}
|
||||||
|
onClick={() => setGoal('lose')}
|
||||||
|
>減脂 (健康減重)</button>
|
||||||
|
<button
|
||||||
|
className={goal === 'maintain' ? 'btn-active' : 'btn-outline'}
|
||||||
|
onClick={() => setGoal('maintain')}
|
||||||
|
>維持現狀 (健康維持)</button>
|
||||||
|
<button
|
||||||
|
className={goal === 'gain' ? 'btn-active' : 'btn-outline'}
|
||||||
|
onClick={() => setGoal('gain')}
|
||||||
|
>增肌 (增加肌肉量)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn-primary mt-4" onClick={handleNext}>計算我的專屬目標</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (() => {
|
||||||
|
const profile = generateProfile(Number(height), Number(weight), Number(age), gender, activity, goal);
|
||||||
|
return (
|
||||||
|
<div className="step-content">
|
||||||
|
<h2>為您量身打造的目標</h2>
|
||||||
|
<div className="summary-cards">
|
||||||
|
<div className="summary-card">
|
||||||
|
<span className="label">BMI 指數</span>
|
||||||
|
<div className="value-group">
|
||||||
|
<span className="value">{profile.bmi}</span>
|
||||||
|
<span className="badge">{getBMICategory(profile.bmi)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="summary-card">
|
||||||
|
<span className="label">TDEE (每日總熱量消耗)</span>
|
||||||
|
<span className="value text-gradient">{profile.tdee} kcal</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="macros-preview mt-4">
|
||||||
|
<h3>建議每日攝取</h3>
|
||||||
|
<div className="macro-row">
|
||||||
|
<span>🔥 總熱量</span>
|
||||||
|
<strong>{profile.targetCalories} kcal</strong>
|
||||||
|
</div>
|
||||||
|
<div className="macro-row">
|
||||||
|
<span style={{color: 'var(--macro-protein)'}}>🥩 蛋白質</span>
|
||||||
|
<strong>{profile.targetProtein} g</strong>
|
||||||
|
</div>
|
||||||
|
<div className="macro-row">
|
||||||
|
<span style={{color: 'var(--macro-fat)'}}>🥑 脂肪</span>
|
||||||
|
<strong>{profile.targetFat} g</strong>
|
||||||
|
</div>
|
||||||
|
<div className="macro-row">
|
||||||
|
<span style={{color: 'var(--macro-carbs)'}}>🍚 碳水化合物</span>
|
||||||
|
<strong>{profile.targetCarbs} g</strong>
|
||||||
|
</div>
|
||||||
|
<div className="macro-row mt-2">
|
||||||
|
<span style={{color: 'var(--water-color)'}}>💧 建議飲水量</span>
|
||||||
|
<strong>{profile.targetWater} ml</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary mt-4" onClick={handleComplete}>完成設定,開始使用</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Onboarding;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.danger-zone {
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
background: rgba(239, 68, 68, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone h2 {
|
||||||
|
color: var(--accent-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn {
|
||||||
|
color: var(--accent-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAppData } from '../context/AppDataContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { generateProfile } from '../utils/calculations';
|
||||||
|
import './Settings.css';
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
const { profile, saveProfile, clearData } = useAppData();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
const [weight, setWeight] = useState(profile.weight.toString());
|
||||||
|
const [activity, setActivity] = useState(profile.activityLevel);
|
||||||
|
const [goal, setGoal] = useState(profile.goal);
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
if (window.confirm('更新體重/活動量會重新計算您的 TDEE 與每日營養目標,確定要更新嗎?')) {
|
||||||
|
const newProfile = generateProfile(
|
||||||
|
profile.height,
|
||||||
|
Number(weight),
|
||||||
|
profile.age,
|
||||||
|
profile.gender,
|
||||||
|
activity,
|
||||||
|
goal
|
||||||
|
);
|
||||||
|
saveProfile(newProfile);
|
||||||
|
alert('已更新您的營養目標!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (window.confirm('確定要清除所有資料嗎?這將會刪除您的個人檔案與所有歷史紀錄。')) {
|
||||||
|
clearData();
|
||||||
|
navigate('/onboarding');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-container page-container animate-fade-in">
|
||||||
|
<header className="mb-4">
|
||||||
|
<h1>設定</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="glass-card mb-4">
|
||||||
|
<h2>個人狀態更新</h2>
|
||||||
|
<p style={{ fontSize: 13 }}>當前目標:{profile.targetCalories} kcal / 飲水:{profile.targetWater} ml</p>
|
||||||
|
|
||||||
|
<div className="form-group mt-4">
|
||||||
|
<label>目前體重 (kg)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="input-field"
|
||||||
|
value={weight}
|
||||||
|
onChange={(e) => setWeight(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>活動量</label>
|
||||||
|
<select className="input-field" value={activity} onChange={(e) => setActivity(e.target.value as any)}>
|
||||||
|
<option value="sedentary">久坐 (辦公室/少運動)</option>
|
||||||
|
<option value="light">輕度活動 (每週運動1-3天)</option>
|
||||||
|
<option value="moderate">中度活動 (每週運動3-5天)</option>
|
||||||
|
<option value="active">高度活動 (每週運動6-7天)</option>
|
||||||
|
<option value="very_active">極度活動 (體力勞動/每天訓練2次)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>目標</label>
|
||||||
|
<select className="input-field" value={goal} onChange={(e) => setGoal(e.target.value as any)}>
|
||||||
|
<option value="lose">減脂</option>
|
||||||
|
<option value="maintain">維持</option>
|
||||||
|
<option value="gain">增肌</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary mt-2" onClick={handleUpdate}>重新計算目標</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card danger-zone">
|
||||||
|
<h2>危險區域</h2>
|
||||||
|
<button className="btn-secondary danger-btn mt-2" onClick={handleReset}>清除所有資料並重新開始</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export type Gender = 'male' | 'female';
|
||||||
|
export type Goal = 'lose' | 'maintain' | 'gain';
|
||||||
|
export type ActivityLevel = 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active';
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
height: number; // cm
|
||||||
|
weight: number; // kg
|
||||||
|
age: number;
|
||||||
|
gender: Gender;
|
||||||
|
activityLevel: ActivityLevel;
|
||||||
|
goal: Goal;
|
||||||
|
|
||||||
|
// Calculated
|
||||||
|
bmi: number;
|
||||||
|
tdee: number;
|
||||||
|
|
||||||
|
// Targets
|
||||||
|
targetCalories: number;
|
||||||
|
targetProtein: number;
|
||||||
|
targetFat: number;
|
||||||
|
targetCarbs: number;
|
||||||
|
targetWater: number; // ml
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Meal {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
time: string;
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
fat: number;
|
||||||
|
carbs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyLog {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
meals: Meal[];
|
||||||
|
waterIntake: number; // ml
|
||||||
|
weight?: number; // kg (Optional daily weight record)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Gender, ActivityLevel, Goal, UserProfile } from '../types';
|
||||||
|
|
||||||
|
export const calculateBMI = (weight: number, height: number): number => {
|
||||||
|
const heightMeters = height / 100;
|
||||||
|
return Number((weight / (heightMeters * heightMeters)).toFixed(1));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBMICategory = (bmi: number): string => {
|
||||||
|
if (bmi < 18.5) return '過輕';
|
||||||
|
if (bmi < 24) return '正常';
|
||||||
|
if (bmi < 27) return '過重';
|
||||||
|
return '肥胖';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateBMR = (weight: number, height: number, age: number, gender: Gender): number => {
|
||||||
|
// Mifflin-St Jeor Equation
|
||||||
|
const base = 10 * weight + 6.25 * height - 5 * age;
|
||||||
|
return gender === 'male' ? base + 5 : base - 161;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateTDEE = (bmr: number, activityLevel: ActivityLevel): number => {
|
||||||
|
const multipliers: Record<ActivityLevel, number> = {
|
||||||
|
sedentary: 1.2,
|
||||||
|
light: 1.375,
|
||||||
|
moderate: 1.55,
|
||||||
|
active: 1.725,
|
||||||
|
very_active: 1.9
|
||||||
|
};
|
||||||
|
return Math.round(bmr * multipliers[activityLevel]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateTargets = (tdee: number, weight: number, goal: Goal) => {
|
||||||
|
let targetCalories = tdee;
|
||||||
|
if (goal === 'lose') targetCalories -= 300; // 500 can be too aggressive, use 300-500
|
||||||
|
if (goal === 'gain') targetCalories += 300;
|
||||||
|
|
||||||
|
// Macros calculation
|
||||||
|
// Protein: roughly 1.6g - 2.2g per kg depending on goal
|
||||||
|
let targetProtein = weight * 1.8;
|
||||||
|
if (goal === 'lose') targetProtein = weight * 2.0; // Higher protein when cutting
|
||||||
|
if (goal === 'gain') targetProtein = weight * 1.6;
|
||||||
|
|
||||||
|
// Fat: roughly 25-30% of total calories
|
||||||
|
const targetFat = (targetCalories * 0.25) / 9;
|
||||||
|
|
||||||
|
// Carbs: The rest
|
||||||
|
const proteinCals = targetProtein * 4;
|
||||||
|
const fatCals = targetFat * 9;
|
||||||
|
const targetCarbs = (targetCalories - proteinCals - fatCals) / 4;
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetCalories: Math.round(targetCalories),
|
||||||
|
targetProtein: Math.round(targetProtein),
|
||||||
|
targetFat: Math.round(targetFat),
|
||||||
|
targetCarbs: Math.round(targetCarbs),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateWaterIntake = (weight: number): number => {
|
||||||
|
// 35ml per kg
|
||||||
|
return Math.round(weight * 35);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateProfile = (
|
||||||
|
height: number,
|
||||||
|
weight: number,
|
||||||
|
age: number,
|
||||||
|
gender: Gender,
|
||||||
|
activityLevel: ActivityLevel,
|
||||||
|
goal: Goal
|
||||||
|
): UserProfile => {
|
||||||
|
const bmi = calculateBMI(weight, height);
|
||||||
|
const bmr = calculateBMR(weight, height, age, gender);
|
||||||
|
const tdee = calculateTDEE(bmr, activityLevel);
|
||||||
|
const targets = calculateTargets(tdee, weight, goal);
|
||||||
|
const targetWater = calculateWaterIntake(weight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
height,
|
||||||
|
weight,
|
||||||
|
age,
|
||||||
|
gender,
|
||||||
|
activityLevel,
|
||||||
|
goal,
|
||||||
|
bmi,
|
||||||
|
tdee,
|
||||||
|
...targets,
|
||||||
|
targetWater,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||||
|
import babel from '@rolldown/plugin-babel'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
babel({ presets: [reactCompilerPreset()] })
|
||||||
|
],
|
||||||
|
})
|
||||||
Reference in new issue
Block a user