diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..debd6cb
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,21 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+RUN python -c "from playwright.sync_api import sync_playwright; \
+ p = sync_playwright().start(); \
+ p.chromium.download(); \
+ p.stop()" || echo "Playwright browser download skipped"
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
new file mode 100644
index 0000000..553f854
--- /dev/null
+++ b/backend/app/api/admin.py
@@ -0,0 +1,20 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+from app.database import get_db
+
+router = APIRouter()
+
+
+@router.post("/scrape")
+def trigger_scrape(db: Session = Depends(get_db)):
+ return {"message": "Scrape trigger - not yet implemented"}
+
+
+@router.get("/logs")
+def get_logs(db: Session = Depends(get_db)):
+ return {"message": "Logs endpoint - not yet implemented"}
+
+
+@router.post("/test-email")
+def test_email(db: Session = Depends(get_db)):
+ return {"message": "Test email - not yet implemented"}
diff --git a/backend/app/api/meals.py b/backend/app/api/meals.py
new file mode 100644
index 0000000..3e46f02
--- /dev/null
+++ b/backend/app/api/meals.py
@@ -0,0 +1,20 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+from app.database import get_db
+
+router = APIRouter()
+
+
+@router.get("/planned")
+def get_planned_meals(db: Session = Depends(get_db)):
+ return {"message": "Planned meals endpoint - not yet implemented"}
+
+
+@router.post("/{meal_id}/approve")
+def approve_meal(meal_id: str, db: Session = Depends(get_db)):
+ return {"message": f"Approve meal {meal_id} - not yet implemented"}
+
+
+@router.post("/{meal_id}/deny")
+def deny_meal(meal_id: str, db: Session = Depends(get_db)):
+ return {"message": f"Deny meal {meal_id} - not yet implemented"}
diff --git a/backend/app/api/pantry.py b/backend/app/api/pantry.py
new file mode 100644
index 0000000..4ac1563
--- /dev/null
+++ b/backend/app/api/pantry.py
@@ -0,0 +1,20 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+from app.database import get_db
+
+router = APIRouter()
+
+
+@router.get("/")
+def get_pantry(db: Session = Depends(get_db)):
+ return {"message": "Pantry endpoint - not yet implemented"}
+
+
+@router.post("/")
+def add_pantry_item(db: Session = Depends(get_db)):
+ return {"message": "Add pantry item - not yet implemented"}
+
+
+@router.delete("/{item_id}")
+def remove_pantry_item(item_id: str, db: Session = Depends(get_db)):
+ return {"message": f"Remove pantry item {item_id} - not yet implemented"}
diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py
new file mode 100644
index 0000000..ba31938
--- /dev/null
+++ b/backend/app/api/profile.py
@@ -0,0 +1,15 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+from app.database import get_db
+
+router = APIRouter()
+
+
+@router.get("/")
+def get_profile(db: Session = Depends(get_db)):
+ return {"message": "Profile endpoint - not yet implemented"}
+
+
+@router.put("/")
+def update_profile(db: Session = Depends(get_db)):
+ return {"message": "Update profile endpoint - not yet implemented"}
diff --git a/backend/app/api/recipes.py b/backend/app/api/recipes.py
new file mode 100644
index 0000000..f08b849
--- /dev/null
+++ b/backend/app/api/recipes.py
@@ -0,0 +1,20 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+from app.database import get_db
+
+router = APIRouter()
+
+
+@router.get("/")
+def get_recipes(db: Session = Depends(get_db)):
+ return {"message": "Recipes endpoint - not yet implemented"}
+
+
+@router.get("/{recipe_id}")
+def get_recipe(recipe_id: str, db: Session = Depends(get_db)):
+ return {"message": f"Recipe {recipe_id} - not yet implemented"}
+
+
+@router.post("/")
+def create_recipe(db: Session = Depends(get_db)):
+ return {"message": "Create recipe endpoint - not yet implemented"}
diff --git a/backend/app/api/shopping_list.py b/backend/app/api/shopping_list.py
new file mode 100644
index 0000000..aa97e93
--- /dev/null
+++ b/backend/app/api/shopping_list.py
@@ -0,0 +1,15 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+from app.database import get_db
+
+router = APIRouter()
+
+
+@router.get("/")
+def get_shopping_list(db: Session = Depends(get_db)):
+ return {"message": "Shopping list endpoint - not yet implemented"}
+
+
+@router.get("/print")
+def get_printable_shopping_list(db: Session = Depends(get_db)):
+ return {"message": "Printable shopping list - not yet implemented"}
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..ec93e31
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,23 @@
+from pydantic_settings import BaseSettings
+from typing import Optional
+
+
+class Settings(BaseSettings):
+ DATABASE_URL: str
+ SENDGRID_API_KEY: Optional[str] = None
+ LUCKY_CA_URL: str = "https://www.luckyncal.com"
+ AI_IMAGE_ENABLED: bool = False
+ AI_IMAGE_PROVIDER: Optional[str] = None
+ AI_IMAGE_API_KEY: Optional[str] = None
+ LOG_LEVEL: str = "INFO"
+ SECRET_KEY: str = "dev-secret-key"
+
+ FAMILY_EMAIL_1: Optional[str] = None
+ FAMILY_EMAIL_2: Optional[str] = None
+ RECIPES_EMAIL: Optional[str] = None
+
+ class Config:
+ env_file = ".env"
+
+
+settings = Settings()
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..cfe8463
--- /dev/null
+++ b/backend/app/database.py
@@ -0,0 +1,16 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, declarative_base
+from app.config import settings
+
+engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base = declarative_base()
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..8827447
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,41 @@
+from fastapi import FastAPI, Depends
+from sqlalchemy.orm import Session
+from app.database import get_db, engine, Base
+from app.models import family_profile, ingredient, recipe, meal_plan, home_pantry, feedback, grocery_item, scrape_log, email_log
+from app.config import settings
+import logging
+
+logging.basicConfig(level=settings.LOG_LEVEL)
+logger = logging.getLogger(__name__)
+
+app = FastAPI(
+ title="MealPlanner",
+ description="Self-hosted meal planning system",
+ version="0.1.0",
+)
+
+Base.metadata.create_all(bind=engine)
+
+
+@app.get("/health")
+def health_check(db: Session = Depends(get_db)):
+ return {"status": "ok"}
+
+
+@app.get("/health/db")
+def health_check_db(db: Session = Depends(get_db)):
+ try:
+ db.execute("SELECT 1")
+ return {"status": "ok", "database": "connected"}
+ except Exception as e:
+ return {"status": "error", "database": "disconnected", "error": str(e)}
+
+
+from app.api import profile, recipes, meals, shopping_list, pantry, admin
+
+app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
+app.include_router(recipes.router, prefix="/api/recipes", tags=["recipes"])
+app.include_router(meals.router, prefix="/api/meals", tags=["meals"])
+app.include_router(shopping_list.router, prefix="/api/shopping-list", tags=["shopping-list"])
+app.include_router(pantry.router, prefix="/api/pantry", tags=["pantry"])
+app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..d601d2f
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,248 @@
+from sqlalchemy import (
+ Column, String, Integer, Numeric, Text, Boolean, Date, DateTime,
+ ForeignKey, CheckConstraint, UniqueConstraint, ARRAY
+)
+from sqlalchemy.dialects.postgresql import UUID, JSONB
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from app.database import Base
+import uuid
+
+
+class FamilyProfile(Base):
+ __tablename__ = "family_profile"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ name = Column(String(100), nullable=False)
+ household_size = Column(Integer, nullable=False)
+ adult_count = Column(Integer, nullable=False)
+ child_count = Column(Integer, nullable=False)
+ dietary_notes = Column(Text)
+ budget_per_meal = Column(Numeric(10, 2), default=50.00)
+ calorie_target = Column(Integer)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+ recipes = relationship("Recipe", back_populates="family_profile")
+ meal_plans = relationship("MealPlan", back_populates="family_profile")
+ pantry_items = relationship("HomePantry", back_populates="family_profile")
+ feedbacks = relationship("Feedback", back_populates="family_profile")
+ never_suggests = relationship("NeverSuggest", back_populates="family_profile")
+
+
+class Ingredient(Base):
+ __tablename__ = "ingredient"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ name = Column(String(200), nullable=False, unique=True)
+ plural_name = Column(String(200))
+ aisle = Column(String(100))
+ typical_price = Column(Numeric(10, 2))
+ unit = Column(String(50))
+ season_months = Column(ARRAY(Integer))
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ recipes = relationship("RecipeIngredient", back_populates="ingredient")
+ pantry_items = relationship("HomePantry", back_populates="ingredient")
+ never_suggests = relationship("NeverSuggest", back_populates="ingredient")
+
+
+class Recipe(Base):
+ __tablename__ = "recipe"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
+ name = Column(String(300), nullable=False)
+ description = Column(Text)
+ image_url = Column(Text)
+ image_source = Column(String(50))
+ prep_time_minutes = Column(Integer)
+ cook_time_minutes = Column(Integer)
+ servings = Column(Integer, nullable=False)
+ servings_scaled = Column(Integer)
+ cuisine_tags = Column(ARRAY(String(50)))
+ dietary_tags = Column(ARRAY(String(50)))
+ protein_type = Column(String(50))
+ spice_level = Column(Integer)
+ ingredients = Column(JSONB, nullable=False)
+ instructions = Column(ARRAY(Text), nullable=False)
+ source_url = Column(Text)
+ scraped_at = Column(DateTime(timezone=True))
+ is_manually_added = Column(Boolean, default=False)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+ family_profile = relationship("FamilyProfile", back_populates="recipes")
+ meal_plan_items = relationship("MealPlanItem", back_populates="recipe")
+
+
+class MealPlan(Base):
+ __tablename__ = "meal_plan"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
+ week_start_date = Column(Date, nullable=False)
+ status = Column(String(20), nullable=False, default="draft")
+ approval_deadline = Column(DateTime(timezone=True))
+ total_estimated_cost = Column(Numeric(10, 2))
+ notes = Column(Text)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+ __table_args__ = (
+ UniqueConstraint("family_profile_id", "week_start_date"),
+ )
+
+ family_profile = relationship("FamilyProfile", back_populates="meal_plans")
+ items = relationship("MealPlanItem", back_populates="meal_plan", cascade="all, delete-orphan")
+
+
+class MealPlanItem(Base):
+ __tablename__ = "meal_plan_item"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id", ondelete="CASCADE"))
+ recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
+ day_of_week = Column(Integer, nullable=False)
+ meal_type = Column(String(20), nullable=False)
+ approval_status = Column(String(20), default="pending")
+ approval_token = Column(UUID(as_uuid=True), unique=True, default=uuid.uuid4)
+ approval_token_expires = Column(DateTime(timezone=True))
+ denial_reason = Column(String(50))
+ denial_details = Column(Text)
+ estimated_cost = Column(Numeric(10, 2))
+ used_pantry_items = Column(ARRAY(UUID(as_uuid=True)))
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+ __table_args__ = (
+ UniqueConstraint("meal_plan_id", "day_of_week", "meal_type"),
+ CheckConstraint("day_of_week BETWEEN 0 AND 6"),
+ )
+
+ meal_plan = relationship("MealPlan", back_populates="items")
+ recipe = relationship("Recipe", back_populates="meal_plan_items")
+ feedback = relationship("Feedback", back_populates="meal_plan_item", uselist=False)
+
+
+class HomePantry(Base):
+ __tablename__ = "home_pantry"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
+ ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
+ quantity = Column(Numeric(10, 2))
+ unit = Column(String(50))
+ expires_at = Column(Date)
+ added_at = Column(DateTime(timezone=True), server_default=func.now())
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ __table_args__ = (
+ UniqueConstraint("family_profile_id", "ingredient_id"),
+ )
+
+ family_profile = relationship("FamilyProfile", back_populates="pantry_items")
+ ingredient = relationship("Ingredient", back_populates="pantry_items")
+
+
+class Feedback(Base):
+ __tablename__ = "feedback"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id"))
+ meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id", ondelete="CASCADE"))
+ rating = Column(Integer)
+ never_suggest = Column(Boolean, default=False)
+ denial_reason = Column(String(50))
+ feedback_text = Column(Text)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ __table_args__ = (
+ UniqueConstraint("meal_plan_item_id"),
+ CheckConstraint("rating BETWEEN 1 AND 5"),
+ )
+
+ family_profile = relationship("FamilyProfile", back_populates="feedbacks")
+ meal_plan_item = relationship("MealPlanItem", back_populates="feedback")
+
+
+class NeverSuggest(Base):
+ __tablename__ = "never_suggest"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ family_profile_id = Column(UUID(as_uuid=True), ForeignKey("family_profile.id", ondelete="CASCADE"))
+ ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
+ recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id"))
+ reason = Column(String(50))
+ notes = Column(Text)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+ family_profile = relationship("FamilyProfile", back_populates="never_suggests")
+ ingredient = relationship("Ingredient", back_populates="never_suggests")
+
+
+class GroceryItem(Base):
+ __tablename__ = "grocery_item"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ name = Column(String(300), nullable=False)
+ brand = Column(String(200))
+ current_price = Column(Numeric(10, 2))
+ regular_price = Column(Numeric(10, 2))
+ unit = Column(String(50))
+ aisle = Column(String(100))
+ image_url = Column(Text)
+ product_url = Column(Text)
+ is_on_sale = Column(Boolean, default=False)
+ sale_start_date = Column(Date)
+ sale_end_date = Column(Date)
+ in_season = Column(Boolean, default=False)
+ scraped_at = Column(DateTime(timezone=True), server_default=func.now())
+ scraped_url = Column(Text)
+
+
+class ScrapeLog(Base):
+ __tablename__ = "scrape_log"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ source = Column(String(50), nullable=False)
+ scrape_type = Column(String(50), nullable=False)
+ status = Column(String(20), nullable=False)
+ items_scraped = Column(Integer, default=0)
+ error_message = Column(Text)
+ started_at = Column(DateTime(timezone=True), server_default=func.now())
+ completed_at = Column(DateTime(timezone=True))
+ duration_seconds = Column(Integer)
+
+
+class EmailLog(Base):
+ __tablename__ = "email_log"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ recipient_email = Column(String(300), nullable=False)
+ recipient_name = Column(String(200))
+ template = Column(String(100), nullable=False)
+ meal_plan_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan.id"))
+ meal_plan_item_id = Column(UUID(as_uuid=True), ForeignKey("meal_plan_item.id"))
+ sendgrid_message_id = Column(String(100))
+ status = Column(String(20), nullable=False)
+ error_message = Column(Text)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ delivered_at = Column(DateTime(timezone=True))
+
+ meal_plan = relationship("MealPlan")
+ meal_plan_item = relationship("MealPlanItem")
+
+
+class RecipeIngredient(Base):
+ __tablename__ = "recipe_ingredient"
+
+ id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ recipe_id = Column(UUID(as_uuid=True), ForeignKey("recipe.id", ondelete="CASCADE"))
+ ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredient.id"))
+ quantity = Column(Numeric(10, 2))
+ unit = Column(String(50))
+ is_optional = Column(Boolean, default=False)
+
+ recipe = relationship("Recipe", back_populates="recipe_ingredients")
+ ingredient = relationship("Ingredient", back_populates="recipes")
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..81b1fb3
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,16 @@
+fastapi==0.109.0
+uvicorn[standard]==0.27.0
+sqlalchemy==2.0.25
+alembic==1.13.1
+psycopg2-binary==2.9.9
+pydantic==2.5.3
+pydantic-settings==2.1.0
+sendgrid==6.12.0
+playwright==1.41.0
+beautifulsoup4==4.12.3
+lxml==5.1.0
+apscheduler==3.10.4
+python-dotenv==1.0.0
+httpx==0.26.0
+pytest==7.4.4
+pytest-asyncio==0.23.3
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..938a294
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,64 @@
+version: "3.8"
+
+services:
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ ports:
+ - "8000:8000"
+ environment:
+ - DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner
+ - SENDGRID_API_KEY=${SENDGRID_API_KEY}
+ - LUCKY_CA_URL=${LUCKY_CA_URL}
+ - AI_IMAGE_ENABLED=${AI_IMAGE_ENABLED}
+ - LOG_LEVEL=${LOG_LEVEL:-INFO}
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ ports:
+ - "3000:80"
+ depends_on:
+ - backend
+ restart: unless-stopped
+
+ db:
+ image: postgres:15-alpine
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ environment:
+ - POSTGRES_USER=mealplanner
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - POSTGRES_DB=mealplanner
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U mealplanner -d mealplanner"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ nginx:
+ image: nginx:alpine
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ depends_on:
+ - frontend
+ - backend
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..3d916e4
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,17 @@
+FROM node:18-alpine AS builder
+
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm ci
+
+COPY . .
+RUN npm run build
+
+FROM nginx:alpine
+COPY --from=builder /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..d6ef0e0
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,19 @@
+server {
+ listen 80;
+ server_name localhost;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ location /api/ {
+ proxy_pass http://backend:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection 'upgrade';
+ proxy_set_header Host $host;
+ proxy_cache_bypass $http_upgrade;
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..b0875c1
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "mealplanner-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.21.1",
+ "@tanstack/react-query": "^5.17.0",
+ "axios": "^1.6.5"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.48",
+ "@types/react-dom": "^18.2.18",
+ "@vitejs/plugin-react": "^4.2.1",
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.33",
+ "tailwindcss": "^3.4.1",
+ "typescript": "^5.3.3",
+ "vite": "^5.0.11",
+ "eslint": "^8.56.0"
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..8537cfe
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,41 @@
+import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import Dashboard from './pages/Dashboard'
+import MealDetail from './pages/MealDetail'
+import Pantry from './pages/Pantry'
+
+const queryClient = new QueryClient()
+
+function App() {
+ return (
+
Meal planning UI coming soon...
+Meal {id} details coming soon...
+