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 ( + + +
+ +
+ + } /> + } /> + } /> + +
+
+
+
+ ) +} + +export default App diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..56a1688 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,10 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..57b3dfc --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,8 @@ +export default function Dashboard() { + return ( +
+

This Week's Meal Plan

+

Meal planning UI coming soon...

+
+ ) +} diff --git a/frontend/src/pages/MealDetail.tsx b/frontend/src/pages/MealDetail.tsx new file mode 100644 index 0000000..69546c3 --- /dev/null +++ b/frontend/src/pages/MealDetail.tsx @@ -0,0 +1,11 @@ +import { useParams } from 'react-router-dom' + +export default function MealDetail() { + const { id } = useParams() + return ( +
+

Meal Detail

+

Meal {id} details coming soon...

+
+ ) +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3934b8f --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..91a0315 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..d6ef0e0 --- /dev/null +++ b/nginx/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; + } +}