mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 07:28:47 +01:00
Initial project scaffolding for health tracker app
Set up backend and frontend structure for a health and fitness tracker using Python (FastAPI, SQLModel, DSPy) and React. Includes Docker and Compose configs, authentication, nutrition AI module, health/nutrition/user endpoints, database models, and basic frontend with routing and context. Enables tracking nutrition, health metrics, and user management, with architecture ready for future mobile and cloud deployment.
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
OPENAI_API_KEY=your_key_here
|
||||
POSTGRES_USER=your_user
|
||||
POSTGRES_PASSWORD=your_password
|
||||
POSTGRES_DB=your_db_name
|
||||
VITE_API_URL=http://your_host:8000
|
||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment Variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
|
||||
# Node / Frontend
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.npm-cache/
|
||||
dist/
|
||||
build/
|
||||
.output/
|
||||
|
||||
# Docker
|
||||
postgres_data/
|
||||
|
||||
# IDE settings
|
||||
.vscode/
|
||||
.idea/
|
||||
11
README.md
11
README.md
@@ -1,2 +1,13 @@
|
||||
# healthy-fit
|
||||
Health and Fitness tracker
|
||||
|
||||
|
||||
Initial prompt:
|
||||
|
||||
I need you to help me develop an application using Python and React, the main goal for the app is to track health metrics: nutrition (macros, calories, minerals), exercise (walking, weight lifting, sports like tennis) and everything else related to living a healthy lifestyle.
|
||||
|
||||
We can introduce an AI component to the application and it'll be ideal to leverage DSPy to enhance the engineering of prompts. Advanced features could include RAG and a Knowledge graph, I still don't have an use case for these, so, help me think how these approaches could be used in the app. Vector/semantic search or GraphRAG could add some value so also consider those.
|
||||
|
||||
The user will be able to easily track the macros, nutrient and calories they consume by just uploading a picture from their phone, and then refine the details manually. This information would be saved to a DB of some sort (help me define which would be best) and then be used across the different app modules. The user can track their blood indicators such as cholesterol, vitamin D3, testosterone and more. Also the weight, height and set goals to improve health. The app can help the user create a diet and exercise to gain musle and or lose weight/fat depending on their preference.
|
||||
|
||||
The architecture of the app must support initially a web version, but in the future the idea is to launch it to the Apple Store and Google Play Store. We are exclusively focusing on open source technologies that can be deployed to any cloud such as AWS, GCP or Azure or even on-premise or private VPS running Linux. Docker and Kubernetes are the preferred platform to run the app and services.
|
||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/ai/__init__.py
Normal file
0
backend/app/ai/__init__.py
Normal file
25
backend/app/ai/nutrition.py
Normal file
25
backend/app/ai/nutrition.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import dspy
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class NutritionalInfo(BaseModel):
|
||||
name: str
|
||||
calories: float
|
||||
protein: float
|
||||
carbs: float
|
||||
fats: float
|
||||
micros: dict | None = None
|
||||
|
||||
class ExtractNutrition(dspy.Signature):
|
||||
"""Extract nutritional information from a food description."""
|
||||
description: str = dspy.InputField(desc="Description of the food or meal")
|
||||
nutritional_info: NutritionalInfo = dspy.OutputField(desc="Nutritional information as a structured object")
|
||||
|
||||
class NutritionModule(dspy.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.extract = dspy.ChainOfThought(ExtractNutrition)
|
||||
|
||||
def forward(self, description: str):
|
||||
return self.extract(description=description)
|
||||
|
||||
nutrition_module = NutritionModule()
|
||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
43
backend/app/api/deps.py
Normal file
43
backend/app/api/deps.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from typing import Generator
|
||||
from sqlmodel import Session
|
||||
from app.db import engine
|
||||
|
||||
from typing import Generator, Annotated
|
||||
from sqlmodel import Session, select
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.db import engine
|
||||
from app.models.user import User
|
||||
from app.core import security
|
||||
from app.config import settings
|
||||
from app.schemas.token import TokenPayload
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token")
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
SessionDep = Annotated[Session, Depends(get_session)]
|
||||
TokenDep = Annotated[str, Depends(oauth2_scheme)]
|
||||
|
||||
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||
)
|
||||
token_data = TokenPayload(**payload)
|
||||
except (JWTError, ValidationError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
user = session.get(User, int(token_data.sub))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
8
backend/app/api/v1/api.py
Normal file
8
backend/app/api/v1/api.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import users, login, nutrition, health
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, tags=["login"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"])
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
0
backend/app/api/v1/endpoints/__init__.py
Normal file
0
backend/app/api/v1/endpoints/__init__.py
Normal file
35
backend/app/api/v1/endpoints/health.py
Normal file
35
backend/app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Any, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
from app.api import deps
|
||||
from app.models.health import HealthMetric
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class HealthMetricCreate(BaseModel):
|
||||
metric_type: str
|
||||
value: float
|
||||
unit: str
|
||||
user_id: int # TODO: remove when auth is fully integrated
|
||||
|
||||
@router.post("/", response_model=HealthMetric)
|
||||
def create_metric(
|
||||
*,
|
||||
session: Session = Depends(deps.get_session),
|
||||
metric_in: HealthMetricCreate,
|
||||
) -> Any:
|
||||
metric = HealthMetric(metric_type=metric_in.metric_type, value=metric_in.value, unit=metric_in.unit, user_id=metric_in.user_id)
|
||||
session.add(metric)
|
||||
session.commit()
|
||||
session.refresh(metric)
|
||||
return metric
|
||||
|
||||
@router.get("/{user_id}", response_model=List[HealthMetric])
|
||||
def read_metrics(
|
||||
user_id: int,
|
||||
session: Session = Depends(deps.get_session),
|
||||
) -> Any:
|
||||
statement = select(HealthMetric).where(HealthMetric.user_id == user_id)
|
||||
metrics = session.exec(statement).all()
|
||||
return metrics
|
||||
35
backend/app/api/v1/endpoints/login.py
Normal file
35
backend/app/api/v1/endpoints/login.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api import deps
|
||||
from app.core import security
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.token import Token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/login/access-token", response_model=Token)
|
||||
def login_access_token(
|
||||
session: Session = Depends(deps.get_session),
|
||||
form_data: OAuth2PasswordRequestForm = Depends()
|
||||
) -> Any:
|
||||
"""
|
||||
OAuth2 compatible token login, get an access token for future requests
|
||||
"""
|
||||
statement = select(User).where(User.email == form_data.username)
|
||||
user = session.exec(statement).first()
|
||||
|
||||
if not user or not security.verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return {
|
||||
"access_token": security.create_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
54
backend/app/api/v1/endpoints/nutrition.py
Normal file
54
backend/app/api/v1/endpoints/nutrition.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
from app.api import deps
|
||||
from app.ai.nutrition import nutrition_module, NutritionalInfo
|
||||
from app.core.security import create_access_token # Just ensuring we have auth imports if needed later
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
description: str
|
||||
|
||||
from app.models.food import FoodLog, FoodItem
|
||||
from app.api.deps import get_session
|
||||
from app.core.security import get_password_hash # Not needed
|
||||
from app.config import settings
|
||||
|
||||
@router.post("/analyze", response_model=NutritionalInfo)
|
||||
def analyze_food(
|
||||
request: AnalyzeRequest,
|
||||
) -> Any:
|
||||
"""
|
||||
Analyze food description and return nutritional info using DSPy.
|
||||
"""
|
||||
try:
|
||||
result = nutrition_module(description=request.description)
|
||||
return result.nutritional_info
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/log", response_model=FoodLog)
|
||||
def log_food(
|
||||
*,
|
||||
session: Session = Depends(deps.get_session),
|
||||
nutrition_info: NutritionalInfo,
|
||||
current_user: deps.CurrentUser,
|
||||
) -> Any:
|
||||
"""
|
||||
Save food log to database.
|
||||
"""
|
||||
food_log = FoodLog(
|
||||
user_id=current_user.id,
|
||||
name=nutrition_info.name,
|
||||
calories=nutrition_info.calories,
|
||||
protein=nutrition_info.protein,
|
||||
carbs=nutrition_info.carbs,
|
||||
fats=nutrition_info.fats,
|
||||
)
|
||||
session.add(food_log)
|
||||
session.commit()
|
||||
session.refresh(food_log)
|
||||
return food_log
|
||||
36
backend/app/api/v1/endpoints/users.py
Normal file
36
backend/app/api/v1/endpoints/users.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api import deps
|
||||
from app.core import security
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/", response_model=UserRead)
|
||||
def create_user(
|
||||
*,
|
||||
session: Session = Depends(deps.get_session),
|
||||
user_in: UserCreate,
|
||||
) -> Any:
|
||||
"""
|
||||
Create new user.
|
||||
"""
|
||||
user = session.exec(select(User).where(User.email == user_in.email)).first()
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The user with this email already exists in the system",
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=user_in.email,
|
||||
username=user_in.username,
|
||||
password_hash=security.get_password_hash(user_in.password),
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
13
backend/app/config.py
Normal file
13
backend/app/config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
OPENAI_API_KEY: str | None = None
|
||||
SECRET_KEY: str = "changethis"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
|
||||
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
7
backend/app/core/ai_config.py
Normal file
7
backend/app/core/ai_config.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import dspy
|
||||
from app.config import settings
|
||||
|
||||
def configure_dspy():
|
||||
if settings.OPENAI_API_KEY:
|
||||
lm = dspy.LM("openai/gpt-4o-mini", api_key=settings.OPENAI_API_KEY)
|
||||
dspy.configure(lm=lm)
|
||||
24
backend/app/core/security.py
Normal file
24
backend/app/core/security.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Union
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
14
backend/app/db.py
Normal file
14
backend/app/db.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sqlmodel import SQLModel, create_engine, Session, text
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
def init_db():
|
||||
with Session(engine) as session:
|
||||
session.exec(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
session.commit()
|
||||
SQLModel.metadata.create_all(engine)
|
||||
33
backend/app/main.py
Normal file
33
backend/app/main.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import FastAPI
|
||||
from app.api.v1.api import api_router
|
||||
from app.db import init_db
|
||||
from app.core.ai_config import configure_dspy
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
configure_dspy()
|
||||
yield
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
app = FastAPI(title="Healthy Fit API", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Welcome to Healthy Fit API"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
27
backend/app/models/food.py
Normal file
27
backend/app/models/food.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict
|
||||
from sqlmodel import Field, SQLModel, JSON
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import Column
|
||||
|
||||
class FoodItem(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True)
|
||||
calories: float
|
||||
protein: float
|
||||
carbs: float
|
||||
fats: float
|
||||
micros: Dict = Field(default={}, sa_column=Column(JSON))
|
||||
embedding: List[float] = Field(sa_column=Column(Vector(1536))) # OpenAI embedding size
|
||||
|
||||
class FoodLog(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
food_item_id: Optional[int] = Field(default=None, foreign_key="fooditem.id")
|
||||
name: str # In case no food item is linked or custom entry
|
||||
calories: float
|
||||
protein: float
|
||||
carbs: float
|
||||
fats: float
|
||||
image_url: Optional[str] = None
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
19
backend/app/models/health.py
Normal file
19
backend/app/models/health.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
class HealthMetric(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
metric_type: str = Field(index=True) # e.g., "weight", "cholesterol", "testosterone"
|
||||
value: float
|
||||
unit: str
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class HealthGoal(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id")
|
||||
goal_type: str # e.g., "lose_weight", "gain_muscle"
|
||||
target_value: float
|
||||
target_date: Optional[datetime] = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
10
backend/app/models/user.py
Normal file
10
backend/app/models/user.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
username: str = Field(index=True, unique=True)
|
||||
email: str = Field(index=True, unique=True)
|
||||
password_hash: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
9
backend/app/schemas/token.py
Normal file
9
backend/app/schemas/token.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from typing import Optional
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
class Token(SQLModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenPayload(SQLModel):
|
||||
sub: Optional[str] = None
|
||||
17
backend/app/schemas/user.py
Normal file
17
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from typing import Optional
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
class UserBase(SQLModel):
|
||||
email: str
|
||||
username: str
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str
|
||||
|
||||
class UserRead(UserBase):
|
||||
id: int
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
14
backend/requirements.txt
Normal file
14
backend/requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
pydantic-settings
|
||||
sqlmodel
|
||||
psycopg2-binary
|
||||
pgvector
|
||||
dspy-ai
|
||||
python-multipart
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
bcrypt==4.0.1
|
||||
pytest
|
||||
httpx
|
||||
python-dotenv
|
||||
73
develop.sh
Executable file
73
develop.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Exit on error
|
||||
set -e
|
||||
|
||||
# Load environment variables from .env
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
else
|
||||
echo "Error: .env file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start Database container
|
||||
echo "Starting database container..."
|
||||
docker compose up -d db
|
||||
|
||||
echo "Waiting for database to be ready..."
|
||||
until docker compose exec -T db pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}; do
|
||||
echo "Database is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Set DATABASE_URL for local connection (using mapped port 5435)
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5435/${POSTGRES_DB}"
|
||||
echo "Configured DATABASE_URL for local development"
|
||||
|
||||
# Virtual Environment Setup
|
||||
VENV_DIR="backend/.venv"
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment in $VENV_DIR..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
echo "Activating virtual environment..."
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# Function to handle shutdown
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Stopping services..."
|
||||
# Kill all child processes of this script
|
||||
pkill -P $$
|
||||
exit
|
||||
}
|
||||
|
||||
# Trap signals for cleanup
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
echo "Starting Backend..."
|
||||
# Run in background
|
||||
(cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000) &
|
||||
|
||||
echo "Starting Frontend..."
|
||||
# Run in background
|
||||
(cd frontend && npm run dev) &
|
||||
|
||||
# Wait for process to settle
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
echo "Development environment running!"
|
||||
echo "Backend: http://localhost:8000"
|
||||
echo "Frontend: http://localhost:5173"
|
||||
echo "Press Ctrl+C to stop"
|
||||
|
||||
# Wait indefinitely for background jobs
|
||||
wait
|
||||
50
docker-compose.yml
Normal file
50
docker-compose.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg16
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5435:5432"
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -U user -d healthyfit" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
# - OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
- VITE_API_URL=${VITE_API_URL}
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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?
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
cache=./.npm-cache
|
||||
10
frontend/Dockerfile
Normal file
10
frontend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
16
frontend/README.md
Normal file
16
frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# React + 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 [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4658
frontend/package-lock.json
generated
Normal file
4658
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"recharts": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal 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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
33
frontend/src/App.jsx
Normal file
33
frontend/src/App.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Nutrition from './pages/Nutrition';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-900 text-white font-sans">
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/nutrition" element={
|
||||
<ProtectedRoute>
|
||||
<Nutrition />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Add Health route later */}
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
17
frontend/src/api/client.js
Normal file
17
frontend/src/api/client.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: `${API_URL}/api/v1`,
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
export default client;
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
13
frontend/src/components/ProtectedRoute.jsx
Normal file
13
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { AuthContext } from '../context/AuthContext';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { token, loading } = useContext(AuthContext);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!token) return <Navigate to="/login" />;
|
||||
|
||||
return children;
|
||||
};
|
||||
export default ProtectedRoute;
|
||||
47
frontend/src/context/AuthContext.jsx
Normal file
47
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createContext, useState, useEffect } from 'react';
|
||||
import client from '../api/client';
|
||||
|
||||
export const AuthContext = createContext();
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
// Fetch user profile if needed, or just decode token
|
||||
// For now, we assume user is logged in if token exists
|
||||
// Ideally call /users/me
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const login = async (email, password) => {
|
||||
const formData = new FormData();
|
||||
formData.append('username', email);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await client.post('/login/access-token', formData);
|
||||
const { access_token } = response.data;
|
||||
|
||||
localStorage.setItem('token', access_token);
|
||||
setToken(access_token);
|
||||
// Ideally fetch user details here
|
||||
return true;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, login, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
20
frontend/src/pages/Dashboard.jsx
Normal file
20
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Dashboard = () => {
|
||||
return (
|
||||
<div className="p-8 text-white">
|
||||
<h1 className="text-4xl font-bold mb-8">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Link to="/nutrition" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
|
||||
<h2 className="text-2xl font-bold mb-2">Nutrition Tracker</h2>
|
||||
<p className="text-gray-400">Log meals and view macros.</p>
|
||||
</Link>
|
||||
<Link to="/health" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
|
||||
<h2 className="text-2xl font-bold mb-2">Health Metrics</h2>
|
||||
<p className="text-gray-400">Track weight and blood indicators.</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Dashboard;
|
||||
57
frontend/src/pages/Login.jsx
Normal file
57
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useContext } from 'react';
|
||||
import { AuthContext } from '../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const Login = () => {
|
||||
const { login } = useContext(AuthContext);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError('Invalid credentials');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
|
||||
<div className="w-full max-w-md p-8 bg-gray-800 rounded-lg shadow-lg">
|
||||
<h2 className="text-2xl font-bold mb-6 text-center text-purple-400">Healthy Fit Login</h2>
|
||||
{error && <p className="text-red-500 mb-4">{error}</p>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-2 rounded bg-gray-700 border border-gray-600 focus:outline-none focus:border-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-2 rounded bg-gray-700 border border-gray-600 focus:outline-none focus:border-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-purple-600 hover:bg-purple-700 text-white p-2 rounded font-bold transition">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
88
frontend/src/pages/Nutrition.jsx
Normal file
88
frontend/src/pages/Nutrition.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import client from '../api/client';
|
||||
|
||||
const Nutrition = () => {
|
||||
const [description, setDescription] = useState('');
|
||||
const [analysis, setAnalysis] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await client.post('/nutrition/analyze', { description });
|
||||
setAnalysis(res.data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to analyze');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!analysis) return;
|
||||
try {
|
||||
// Backend extracts user from token via params/dependency
|
||||
await client.post('/nutrition/log', analysis);
|
||||
alert('Saved!');
|
||||
setAnalysis(null);
|
||||
setDescription('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Failed to save');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-6">Nutrition Tracker</h1>
|
||||
<div className="bg-gray-800 p-6 rounded-lg mb-8">
|
||||
<textarea
|
||||
className="w-full p-4 bg-gray-700 rounded mb-4 text-white"
|
||||
rows="4"
|
||||
placeholder="Describe your meal (e.g. 'A chicken breast with a cup of rice')..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 px-6 py-2 rounded text-white font-bold"
|
||||
>
|
||||
{loading ? 'Analyzing...' : 'Analyze'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<div className="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 className="text-2xl font-bold mb-4">Analysis Result</h2>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="p-4 bg-gray-700 rounded">
|
||||
<span className="block text-gray-400">Calories</span>
|
||||
<span className="text-xl font-bold">{analysis.calories}</span>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-700 rounded">
|
||||
<span className="block text-gray-400">Protein</span>
|
||||
<span className="text-xl font-bold">{analysis.protein}g</span>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-700 rounded">
|
||||
<span className="block text-gray-400">Carbs</span>
|
||||
<span className="text-xl font-bold">{analysis.carbs}g</span>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-700 rounded">
|
||||
<span className="block text-gray-400">Fats</span>
|
||||
<span className="text-xl font-bold">{analysis.fats}g</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-green-600 px-6 py-2 rounded text-white font-bold"
|
||||
>
|
||||
Save to Log
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default Nutrition;
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
7
frontend/vite.config.js
Normal file
7
frontend/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user