This commit is contained in:
Jack committed 2026-06-08 12:36:35 +08:00
commit 363c4abdc7
42 files changed
+6105

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
.gitignore
README.md
.gemini
+24
View File
@@ -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
View File
@@ -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;"]
+75
View File
@@ -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...
},
},
])
```
+39
View File
@@ -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:
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+3680
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -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"
}
}
+1
View File
@@ -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

+24
View File
@@ -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

+7
View File
@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
+170
View File
@@ -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}`);
});
+38
View File
@@ -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
);
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+63
View File
@@ -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);
}
+31
View File
@@ -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;
+173
View File
@@ -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
View File
@@ -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; }
+16
View File
@@ -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>,
);
+28
View File
@@ -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;
}
+119
View File
@@ -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;
+31
View File
@@ -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;
}
+121
View File
@@ -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;
+165
View File
@@ -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);
}
+159
View File
@@ -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;
+119
View File
@@ -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;
}
+163
View File
@@ -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;
+17
View File
@@ -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);
}
+90
View File
@@ -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;
+40
View File
@@ -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)
}
+90
View File
@@ -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,
};
};
+25
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -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"]
}
+11
View File
@@ -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()] })
],
})