From 4e71ef9c668f54029aae398b0cd64080693d19ec Mon Sep 17 00:00:00 2001 From: Paul R Kartchner Date: Tue, 21 Oct 2025 22:04:03 -0600 Subject: [PATCH] first commit --- .gitignore | 51 ++++ CLAUDE.md | 202 +++++++++++++++ README.md | 229 +++++++++++++++++ docker-compose.yml | 56 ++++ package.json | 21 ++ packages/api/.env.example | 20 ++ packages/api/.eslintrc.json | 20 ++ packages/api/Dockerfile | 45 ++++ packages/api/package.json | 40 +++ packages/api/prisma/schema.prisma | 90 +++++++ packages/api/src/config/database.ts | 7 + packages/api/src/config/storage.ts | 10 + packages/api/src/index.ts | 33 +++ packages/api/src/routes/recipes.routes.ts | 256 +++++++++++++++++++ packages/api/src/services/scraper.service.ts | 166 ++++++++++++ packages/api/src/services/storage.service.ts | 66 +++++ packages/api/tsconfig.json | 18 ++ packages/shared/package.json | 16 ++ packages/shared/src/index.ts | 1 + packages/shared/src/types.ts | 69 +++++ packages/shared/tsconfig.json | 17 ++ packages/web/.eslintrc.json | 19 ++ packages/web/Dockerfile | 33 +++ packages/web/index.html | 13 + packages/web/nginx.conf | 32 +++ packages/web/package.json | 33 +++ packages/web/src/App.css | 226 ++++++++++++++++ packages/web/src/App.tsx | 41 +++ packages/web/src/main.tsx | 9 + packages/web/src/pages/RecipeDetail.tsx | 118 +++++++++ packages/web/src/pages/RecipeImport.tsx | 130 ++++++++++ packages/web/src/pages/RecipeList.tsx | 72 ++++++ packages/web/src/services/api.ts | 58 +++++ packages/web/tsconfig.json | 25 ++ packages/web/tsconfig.node.json | 10 + packages/web/vite.config.ts | 19 ++ 36 files changed, 2271 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 packages/api/.env.example create mode 100644 packages/api/.eslintrc.json create mode 100644 packages/api/Dockerfile create mode 100644 packages/api/package.json create mode 100644 packages/api/prisma/schema.prisma create mode 100644 packages/api/src/config/database.ts create mode 100644 packages/api/src/config/storage.ts create mode 100644 packages/api/src/index.ts create mode 100644 packages/api/src/routes/recipes.routes.ts create mode 100644 packages/api/src/services/scraper.service.ts create mode 100644 packages/api/src/services/storage.service.ts create mode 100644 packages/api/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/web/.eslintrc.json create mode 100644 packages/web/Dockerfile create mode 100644 packages/web/index.html create mode 100644 packages/web/nginx.conf create mode 100644 packages/web/package.json create mode 100644 packages/web/src/App.css create mode 100644 packages/web/src/App.tsx create mode 100644 packages/web/src/main.tsx create mode 100644 packages/web/src/pages/RecipeDetail.tsx create mode 100644 packages/web/src/pages/RecipeImport.tsx create mode 100644 packages/web/src/pages/RecipeList.tsx create mode 100644 packages/web/src/services/api.ts create mode 100644 packages/web/tsconfig.json create mode 100644 packages/web/tsconfig.node.json create mode 100644 packages/web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86e60f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +coverage/ + +# Production +build/ +dist/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Uploads +uploads/ +public/uploads/ + +# Docker +.docker/ + +# Prisma +packages/api/prisma/migrations/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4eeea06 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,202 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Basil is a full-stack recipe manager built with TypeScript. It features a REST API backend, React web frontend, and PostgreSQL database. The application supports importing recipes from URLs via schema.org markup parsing, local/S3 image storage, and comprehensive recipe management (CRUD operations, search, tagging). + +## Architecture + +**Monorepo Structure:** +- `packages/shared/` - Shared TypeScript types and interfaces used across all packages +- `packages/api/` - Express.js REST API server with Prisma ORM +- `packages/web/` - React + Vite web application +- Future: `packages/mobile/` - React Native mobile apps + +**Key Technologies:** +- Backend: Node.js, TypeScript, Express, Prisma ORM, PostgreSQL +- Frontend: React, TypeScript, Vite, React Router +- Infrastructure: Docker, Docker Compose, nginx +- Recipe Scraping: Cheerio for HTML parsing, Schema.org Recipe markup support + +**Database Schema:** +- `Recipe` - Main recipe entity with metadata (prep/cook time, servings, ratings) +- `Ingredient` - Recipe ingredients with amounts, units, and ordering +- `Instruction` - Step-by-step instructions with optional images +- `RecipeImage` - Multiple images per recipe +- `Tag` / `RecipeTag` - Many-to-many tagging system + +**Storage Architecture:** +- Primary: Local filesystem storage (`./uploads`) +- Optional: S3 storage (configurable via environment variables) +- Storage service implements strategy pattern for easy switching + +## Development Commands + +```bash +# Install all dependencies (root + all packages) +npm install + +# Development mode (starts all packages with hot reload) +npm run dev + +# Build all packages +npm run build + +# Lint all packages +npm run lint + +# Docker commands +npm run docker:up # Start all services (PostgreSQL, API, web) +npm run docker:down # Stop all services +npm run docker:build # Rebuild Docker images +``` + +### Backend-Specific Commands + +```bash +cd packages/api + +# Development with auto-reload +npm run dev + +# Database migrations +npm run prisma:migrate # Create and apply migration +npm run prisma:generate # Generate Prisma client +npm run prisma:studio # Open Prisma Studio GUI + +# Build for production +npm run build +npm start +``` + +### Frontend-Specific Commands + +```bash +cd packages/web + +# Development server +npm run dev + +# Production build +npm run build +npm run preview # Preview production build locally +``` + +## Configuration + +**Environment Variables (packages/api/.env):** +``` +PORT=3001 +NODE_ENV=development +DATABASE_URL=postgresql://basil:basil@localhost:5432/basil?schema=public +STORAGE_TYPE=local # or 's3' +LOCAL_STORAGE_PATH=./uploads +CORS_ORIGIN=http://localhost:5173 +``` + +For S3 storage, add: +``` +S3_BUCKET=basil-recipes +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=your-key +S3_SECRET_ACCESS_KEY=your-secret +``` + +## Key Features + +### Recipe Import from URL +- `POST /api/recipes/import` - Scrapes recipe from URL using schema.org markup +- Extracts: title, description, ingredients, instructions, times, images +- Handles JSON-LD structured data (primary) with fallback to manual parsing +- Downloads and stores images locally or on S3 + +### Recipe Management +- Full CRUD operations on recipes +- Pagination and search (by title, description) +- Filtering by cuisine, category +- Image upload with multiple images per recipe +- Tagging system for organization + +### Storage Service +- Abstracted storage interface in `packages/api/src/services/storage.service.ts` +- Local storage: Saves to filesystem with timestamped filenames +- S3 storage: Placeholder for AWS SDK implementation +- Easy to extend for other storage providers + +## Adding New Features + +### Adding a New API Endpoint +1. Add route handler in `packages/api/src/routes/*.routes.ts` +2. Update shared types in `packages/shared/src/types.ts` if needed +3. Rebuild shared package: `cd packages/shared && npm run build` +4. Use Prisma client for database operations + +### Adding a New Frontend Page +1. Create component in `packages/web/src/pages/` +2. Add route in `packages/web/src/App.tsx` +3. Create API service methods in `packages/web/src/services/api.ts` +4. Import and use shared types from `@basil/shared` + +### Database Schema Changes +1. Edit `packages/api/prisma/schema.prisma` +2. Run `npm run prisma:migrate` to create migration +3. Run `npm run prisma:generate` to update Prisma client +4. Update TypeScript types in `packages/shared/src/types.ts` to match + +## Docker Deployment + +The project includes full Docker support for production deployment: + +```bash +docker-compose up -d +``` + +This starts: +- PostgreSQL database (port 5432) +- API server (port 3001) +- Web frontend served via nginx (port 5173) + +Persistent volumes: +- `postgres_data` - Database storage +- `uploads_data` - Uploaded images + +## API Reference + +**Recipes:** +- `GET /api/recipes` - List all recipes (supports pagination, search, filters) +- `GET /api/recipes/:id` - Get single recipe with all relations +- `POST /api/recipes` - Create new recipe +- `PUT /api/recipes/:id` - Update recipe +- `DELETE /api/recipes/:id` - Delete recipe and associated images +- `POST /api/recipes/:id/images` - Upload image for recipe +- `POST /api/recipes/import` - Import recipe from URL + +**Query Parameters:** +- `page`, `limit` - Pagination +- `search` - Search in title/description +- `cuisine`, `category` - Filter by cuisine or category + +## Important Implementation Details + +### Prisma Relations +- All related entities (ingredients, instructions, images, tags) use cascade delete +- Ingredients and instructions maintain ordering via `order` and `step` fields +- Tags use many-to-many relationship via `RecipeTag` join table + +### Recipe Scraping +- Primary: Parses JSON-LD `application/ld+json` scripts for Schema.org Recipe data +- Fallback: Extracts basic info from HTML meta tags and headings +- Handles ISO 8601 duration format (PT30M, PT1H30M) for cook times +- Downloads images asynchronously and stores them locally + +### Frontend Routing +- Uses React Router v6 for client-side routing +- Vite proxy forwards `/api` and `/uploads` requests to backend during development +- Production uses nginx reverse proxy to backend API + +### TypeScript Workspace +- Root `package.json` defines npm workspaces +- Packages can reference each other (e.g., `@basil/shared`) +- Must rebuild shared package when types change for other packages to see updates diff --git a/README.md b/README.md new file mode 100644 index 0000000..6242413 --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +# Basil 🌿 + +A modern, full-stack recipe manager with web and mobile support. Import recipes from any URL, manage your recipe collection, and access them from anywhere. + +## Features + +- **Recipe Import**: Automatically import recipes from URLs using schema.org markup +- **Full Recipe Management**: Create, read, update, and delete recipes +- **Rich Recipe Data**: Store ingredients, instructions, prep/cook times, servings, images, and more +- **Search & Filter**: Find recipes by title, cuisine, category, or tags +- **Multiple Images**: Add multiple images to each recipe +- **Flexible Storage**: Local filesystem storage by default, optional S3 support +- **Docker Support**: Easy deployment with Docker Compose +- **API-First Design**: RESTful API for web and future mobile apps + +## Tech Stack + +- **Backend**: Node.js, TypeScript, Express, Prisma ORM, PostgreSQL +- **Frontend**: React, TypeScript, Vite, React Router +- **Infrastructure**: Docker, Docker Compose, nginx +- **Monorepo**: npm workspaces with shared types + +## Quick Start + +### Prerequisites + +- Node.js 20+ +- PostgreSQL 16+ (or use Docker) +- Docker (optional, for containerized deployment) + +### Development Setup + +1. **Clone the repository** + ```bash + git clone + cd basil + ``` + +2. **Install dependencies** + ```bash + npm install + ``` + +3. **Set up environment variables** + ```bash + cp packages/api/.env.example packages/api/.env + # Edit packages/api/.env with your database credentials + ``` + +4. **Start PostgreSQL** (if not using Docker) + ```bash + # Create a database named 'basil' + createdb basil + ``` + +5. **Run database migrations** + ```bash + cd packages/api + npm run prisma:migrate + npm run prisma:generate + cd ../.. + ``` + +6. **Start development servers** + ```bash + npm run dev + ``` + + This starts: + - API server at http://localhost:3001 + - Web frontend at http://localhost:5173 + +### Docker Deployment + +For production or easy local setup: + +```bash +# Start all services +docker-compose up -d + +# View logs +docker-compose logs -f + +# Stop services +docker-compose down +``` + +Services will be available at: +- Web: http://localhost:5173 +- API: http://localhost:3001 +- PostgreSQL: localhost:5432 + +## Project Structure + +``` +basil/ +├── packages/ +│ ├── api/ # Backend API server +│ │ ├── prisma/ # Database schema and migrations +│ │ └── src/ # Express routes, services, config +│ ├── web/ # React web application +│ │ └── src/ # Components, pages, services +│ └── shared/ # Shared TypeScript types +├── docker-compose.yml +└── package.json # Workspace root +``` + +## Usage + +### Importing a Recipe + +1. Navigate to "Import Recipe" in the web app +2. Paste a recipe URL (from sites like AllRecipes, Food Network, etc.) +3. Preview the imported recipe +4. Save to your collection + +### API Examples + +**Import recipe from URL:** +```bash +curl -X POST http://localhost:3001/api/recipes/import \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com/recipe"}' +``` + +**Get all recipes:** +```bash +curl http://localhost:3001/api/recipes +``` + +**Create a recipe:** +```bash +curl -X POST http://localhost:3001/api/recipes \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Chocolate Chip Cookies", + "ingredients": [ + {"name": "flour", "amount": "2", "unit": "cups", "order": 0} + ], + "instructions": [ + {"step": 1, "text": "Preheat oven to 350°F"} + ] + }' +``` + +## Configuration + +### Storage Options + +**Local Storage (Default):** +```env +STORAGE_TYPE=local +LOCAL_STORAGE_PATH=./uploads +``` + +**S3 Storage:** +```env +STORAGE_TYPE=s3 +S3_BUCKET=your-bucket-name +S3_REGION=us-east-1 +S3_ACCESS_KEY_ID=your-access-key +S3_SECRET_ACCESS_KEY=your-secret-key +``` + +### Database + +Default PostgreSQL connection: +```env +DATABASE_URL=postgresql://basil:basil@localhost:5432/basil?schema=public +``` + +For external database services (AWS RDS, DigitalOcean, etc.), update the connection string. + +## Development + +### Commands + +```bash +# Install dependencies +npm install + +# Start all services in development mode +npm run dev + +# Build all packages +npm run build + +# Lint all packages +npm run lint + +# Docker commands +npm run docker:up +npm run docker:down +npm run docker:build +``` + +### Database Migrations + +```bash +cd packages/api + +# Create a new migration +npm run prisma:migrate + +# Generate Prisma client +npm run prisma:generate + +# Open Prisma Studio (GUI for database) +npm run prisma:studio +``` + +## Future Enhancements + +- [ ] Mobile apps (React Native for iOS and Android) +- [ ] User authentication and multi-user support +- [ ] Recipe sharing and social features +- [ ] Meal planning and grocery lists +- [ ] Nutritional information calculation +- [ ] Recipe scaling (adjust servings) +- [ ] Print-friendly recipe view +- [ ] Recipe collections and cookbooks + +## License + +MIT + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..53c9a3f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: basil-postgres + restart: unless-stopped + environment: + POSTGRES_USER: basil + POSTGRES_PASSWORD: basil + POSTGRES_DB: basil + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U basil"] + interval: 10s + timeout: 5s + retries: 5 + + api: + build: + context: . + dockerfile: packages/api/Dockerfile + container_name: basil-api + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_URL: postgresql://basil:basil@postgres:5432/basil?schema=public + STORAGE_TYPE: local + LOCAL_STORAGE_PATH: /app/uploads + CORS_ORIGIN: http://localhost:5173 + volumes: + - uploads_data:/app/uploads + ports: + - "3001:3001" + + web: + build: + context: . + dockerfile: packages/web/Dockerfile + container_name: basil-web + restart: unless-stopped + depends_on: + - api + ports: + - "5173:80" + +volumes: + postgres_data: + uploads_data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..943d8f2 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "basil", + "version": "1.0.0", + "description": "A modern recipe manager with web and mobile apps", + "private": true, + "workspaces": [ + "packages/*" + ], + "scripts": { + "dev": "npm run dev --workspaces --if-present", + "build": "npm run build --workspaces --if-present", + "test": "npm run test --workspaces --if-present", + "lint": "npm run lint --workspaces --if-present", + "docker:up": "docker-compose up -d", + "docker:down": "docker-compose down", + "docker:build": "docker-compose build" + }, + "keywords": ["recipe", "cooking", "food", "manager"], + "author": "", + "license": "MIT" +} diff --git a/packages/api/.env.example b/packages/api/.env.example new file mode 100644 index 0000000..fa5c0ea --- /dev/null +++ b/packages/api/.env.example @@ -0,0 +1,20 @@ +# Server Configuration +PORT=3001 +NODE_ENV=development + +# Database +DATABASE_URL="postgresql://basil:basil@localhost:5432/basil?schema=public" + +# Storage Configuration +STORAGE_TYPE=local +LOCAL_STORAGE_PATH=./uploads + +# S3 Configuration (Optional) +# STORAGE_TYPE=s3 +# S3_BUCKET=basil-recipes +# S3_REGION=us-east-1 +# S3_ACCESS_KEY_ID=your-access-key +# S3_SECRET_ACCESS_KEY=your-secret-key + +# CORS +CORS_ORIGIN=http://localhost:5173 diff --git a/packages/api/.eslintrc.json b/packages/api/.eslintrc.json new file mode 100644 index 0000000..679958e --- /dev/null +++ b/packages/api/.eslintrc.json @@ -0,0 +1,20 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "plugins": ["@typescript-eslint"], + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + }, + "env": { + "node": true, + "es6": true + }, + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-module-boundary-types": "off" + } +} diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile new file mode 100644 index 0000000..d223199 --- /dev/null +++ b/packages/api/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy workspace root files +COPY package*.json ./ +COPY packages/shared ./packages/shared +COPY packages/api ./packages/api + +# Install dependencies +RUN npm install + +# Build shared package +WORKDIR /app/packages/shared +RUN npm run build + +# Build API +WORKDIR /app/packages/api +RUN npm run prisma:generate +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy built files and dependencies +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/packages/shared/package.json ./packages/shared/ +COPY --from=builder /app/packages/shared/dist ./packages/shared/dist +COPY --from=builder /app/packages/api/package.json ./packages/api/ +COPY --from=builder /app/packages/api/dist ./packages/api/dist +COPY --from=builder /app/packages/api/prisma ./packages/api/prisma +COPY --from=builder /app/packages/api/node_modules/.prisma ./packages/api/node_modules/.prisma +COPY --from=builder /app/node_modules ./node_modules + +WORKDIR /app/packages/api + +# Create uploads directory +RUN mkdir -p /app/uploads + +EXPOSE 3001 + +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..97545d2 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,40 @@ +{ + "name": "@basil/api", + "version": "1.0.0", + "description": "Basil API server", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:studio": "prisma studio", + "lint": "eslint src --ext .ts" + }, + "keywords": ["basil", "api"], + "license": "MIT", + "dependencies": { + "@basil/shared": "^1.0.0", + "@prisma/client": "^5.8.0", + "express": "^4.18.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "multer": "^1.4.5-lts.1", + "axios": "^1.6.5", + "cheerio": "^1.0.0-rc.12", + "recipe-scraper": "^3.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/multer": "^1.4.11", + "@types/node": "^20.10.6", + "prisma": "^5.8.0", + "tsx": "^4.7.0", + "typescript": "^5.3.3", + "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^6.17.0", + "@typescript-eslint/parser": "^6.17.0" + } +} diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma new file mode 100644 index 0000000..675ed7f --- /dev/null +++ b/packages/api/prisma/schema.prisma @@ -0,0 +1,90 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Recipe { + id String @id @default(cuid()) + title String + description String? + prepTime Int? // minutes + cookTime Int? // minutes + totalTime Int? // minutes + servings Int? + imageUrl String? + sourceUrl String? // For imported recipes + author String? + cuisine String? + category String? + rating Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + ingredients Ingredient[] + instructions Instruction[] + images RecipeImage[] + tags RecipeTag[] + + @@index([title]) + @@index([cuisine]) + @@index([category]) +} + +model Ingredient { + id String @id @default(cuid()) + recipeId String + name String + amount String? + unit String? + notes String? + order Int + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + + @@index([recipeId]) +} + +model Instruction { + id String @id @default(cuid()) + recipeId String + step Int + text String @db.Text + imageUrl String? + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + + @@index([recipeId]) +} + +model RecipeImage { + id String @id @default(cuid()) + recipeId String + url String + order Int + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + + @@index([recipeId]) +} + +model Tag { + id String @id @default(cuid()) + name String @unique + recipes RecipeTag[] +} + +model RecipeTag { + recipeId String + tagId String + + recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([recipeId, tagId]) + @@index([recipeId]) + @@index([tagId]) +} diff --git a/packages/api/src/config/database.ts b/packages/api/src/config/database.ts new file mode 100644 index 0000000..b313a8e --- /dev/null +++ b/packages/api/src/config/database.ts @@ -0,0 +1,7 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], +}); + +export default prisma; diff --git a/packages/api/src/config/storage.ts b/packages/api/src/config/storage.ts new file mode 100644 index 0000000..ca13e53 --- /dev/null +++ b/packages/api/src/config/storage.ts @@ -0,0 +1,10 @@ +import { StorageConfig } from '@basil/shared'; + +export const storageConfig: StorageConfig = { + type: (process.env.STORAGE_TYPE as 'local' | 's3') || 'local', + localPath: process.env.LOCAL_STORAGE_PATH || './uploads', + s3Bucket: process.env.S3_BUCKET, + s3Region: process.env.S3_REGION, + s3AccessKey: process.env.S3_ACCESS_KEY_ID, + s3SecretKey: process.env.S3_SECRET_ACCESS_KEY, +}; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..62fb61a --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,33 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import path from 'path'; +import recipesRoutes from './routes/recipes.routes'; + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Middleware +app.use(cors({ + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', +})); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Serve uploaded files +app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); + +// Routes +app.use('/api/recipes', recipesRoutes); + +// Health check +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// Start server +app.listen(PORT, () => { + console.log(`🌿 Basil API server running on http://localhost:${PORT}`); +}); diff --git a/packages/api/src/routes/recipes.routes.ts b/packages/api/src/routes/recipes.routes.ts new file mode 100644 index 0000000..f99a169 --- /dev/null +++ b/packages/api/src/routes/recipes.routes.ts @@ -0,0 +1,256 @@ +import { Router } from 'express'; +import multer from 'multer'; +import prisma from '../config/database'; +import { StorageService } from '../services/storage.service'; +import { ScraperService } from '../services/scraper.service'; +import { ApiResponse, RecipeImportRequest } from '@basil/shared'; + +const router = Router(); +const upload = multer({ storage: multer.memoryStorage() }); +const storageService = StorageService.getInstance(); +const scraperService = new ScraperService(); + +// Get all recipes +router.get('/', async (req, res) => { + try { + const { page = '1', limit = '20', search, cuisine, category } = req.query; + const pageNum = parseInt(page as string); + const limitNum = parseInt(limit as string); + const skip = (pageNum - 1) * limitNum; + + const where: any = {}; + if (search) { + where.OR = [ + { title: { contains: search as string, mode: 'insensitive' } }, + { description: { contains: search as string, mode: 'insensitive' } }, + ]; + } + if (cuisine) where.cuisine = cuisine; + if (category) where.category = category; + + const [recipes, total] = await Promise.all([ + prisma.recipe.findMany({ + where, + skip, + take: limitNum, + include: { + ingredients: { orderBy: { order: 'asc' } }, + instructions: { orderBy: { step: 'asc' } }, + images: { orderBy: { order: 'asc' } }, + tags: { include: { tag: true } }, + }, + orderBy: { createdAt: 'desc' }, + }), + prisma.recipe.count({ where }), + ]); + + res.json({ + data: recipes, + total, + page: pageNum, + pageSize: limitNum, + }); + } catch (error) { + console.error('Error fetching recipes:', error); + res.status(500).json({ error: 'Failed to fetch recipes' }); + } +}); + +// Get single recipe +router.get('/:id', async (req, res) => { + try { + const recipe = await prisma.recipe.findUnique({ + where: { id: req.params.id }, + include: { + ingredients: { orderBy: { order: 'asc' } }, + instructions: { orderBy: { step: 'asc' } }, + images: { orderBy: { order: 'asc' } }, + tags: { include: { tag: true } }, + }, + }); + + if (!recipe) { + return res.status(404).json({ error: 'Recipe not found' }); + } + + res.json({ data: recipe }); + } catch (error) { + console.error('Error fetching recipe:', error); + res.status(500).json({ error: 'Failed to fetch recipe' }); + } +}); + +// Create recipe +router.post('/', async (req, res) => { + try { + const { title, description, ingredients, instructions, tags, ...recipeData } = req.body; + + const recipe = await prisma.recipe.create({ + data: { + title, + description, + ...recipeData, + ingredients: { + create: ingredients?.map((ing: any, index: number) => ({ + ...ing, + order: ing.order ?? index, + })), + }, + instructions: { + create: instructions?.map((inst: any) => inst), + }, + tags: tags + ? { + create: tags.map((tagName: string) => ({ + tag: { + connectOrCreate: { + where: { name: tagName }, + create: { name: tagName }, + }, + }, + })), + } + : undefined, + }, + include: { + ingredients: true, + instructions: true, + images: true, + tags: { include: { tag: true } }, + }, + }); + + res.status(201).json({ data: recipe }); + } catch (error) { + console.error('Error creating recipe:', error); + res.status(500).json({ error: 'Failed to create recipe' }); + } +}); + +// Update recipe +router.put('/:id', async (req, res) => { + try { + const { ingredients, instructions, tags, ...recipeData } = req.body; + + // Delete existing relations + await prisma.ingredient.deleteMany({ where: { recipeId: req.params.id } }); + await prisma.instruction.deleteMany({ where: { recipeId: req.params.id } }); + await prisma.recipeTag.deleteMany({ where: { recipeId: req.params.id } }); + + const recipe = await prisma.recipe.update({ + where: { id: req.params.id }, + data: { + ...recipeData, + ingredients: ingredients + ? { + create: ingredients.map((ing: any, index: number) => ({ + ...ing, + order: ing.order ?? index, + })), + } + : undefined, + instructions: instructions ? { create: instructions } : undefined, + tags: tags + ? { + create: tags.map((tagName: string) => ({ + tag: { + connectOrCreate: { + where: { name: tagName }, + create: { name: tagName }, + }, + }, + })), + } + : undefined, + }, + include: { + ingredients: true, + instructions: true, + images: true, + tags: { include: { tag: true } }, + }, + }); + + res.json({ data: recipe }); + } catch (error) { + console.error('Error updating recipe:', error); + res.status(500).json({ error: 'Failed to update recipe' }); + } +}); + +// Delete recipe +router.delete('/:id', async (req, res) => { + try { + // Get recipe to delete associated images + const recipe = await prisma.recipe.findUnique({ + where: { id: req.params.id }, + include: { images: true }, + }); + + if (recipe) { + // Delete images from storage + if (recipe.imageUrl) { + await storageService.deleteFile(recipe.imageUrl); + } + for (const image of recipe.images) { + await storageService.deleteFile(image.url); + } + } + + await prisma.recipe.delete({ where: { id: req.params.id } }); + + res.json({ message: 'Recipe deleted successfully' }); + } catch (error) { + console.error('Error deleting recipe:', error); + res.status(500).json({ error: 'Failed to delete recipe' }); + } +}); + +// Upload image +router.post('/:id/images', upload.single('image'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No image provided' }); + } + + const imageUrl = await storageService.saveFile(req.file, 'recipes'); + + // Add to recipe images + const image = await prisma.recipeImage.create({ + data: { + recipeId: req.params.id, + url: imageUrl, + order: 0, + }, + }); + + res.json({ data: image }); + } catch (error) { + console.error('Error uploading image:', error); + res.status(500).json({ error: 'Failed to upload image' }); + } +}); + +// Import recipe from URL +router.post('/import', async (req, res) => { + try { + const { url }: RecipeImportRequest = req.body; + + if (!url) { + return res.status(400).json({ error: 'URL is required' }); + } + + const result = await scraperService.scrapeRecipe(url); + + if (!result.success) { + return res.status(400).json({ error: result.error }); + } + + res.json(result); + } catch (error) { + console.error('Error importing recipe:', error); + res.status(500).json({ error: 'Failed to import recipe' }); + } +}); + +export default router; diff --git a/packages/api/src/services/scraper.service.ts b/packages/api/src/services/scraper.service.ts new file mode 100644 index 0000000..752144d --- /dev/null +++ b/packages/api/src/services/scraper.service.ts @@ -0,0 +1,166 @@ +import axios from 'axios'; +import * as cheerio from 'cheerio'; +import { Recipe, RecipeImportResponse } from '@basil/shared'; + +export class ScraperService { + async scrapeRecipe(url: string): Promise { + try { + const response = await axios.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; BasilBot/1.0)', + }, + timeout: 10000, + }); + + const html = response.data; + const $ = cheerio.load(html); + + // Try to find JSON-LD schema.org Recipe markup + const recipeData = this.extractSchemaOrgRecipe($); + + if (recipeData) { + return { + success: true, + recipe: { + ...recipeData, + sourceUrl: url, + }, + }; + } + + // Fallback to manual parsing if no schema found + const fallbackData = this.extractRecipeFallback($); + + return { + success: true, + recipe: { + ...fallbackData, + sourceUrl: url, + }, + }; + } catch (error) { + console.error('Error scraping recipe:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to scrape recipe', + recipe: {}, + }; + } + } + + private extractSchemaOrgRecipe($: cheerio.CheerioAPI): Partial | null { + const scripts = $('script[type="application/ld+json"]'); + + for (let i = 0; i < scripts.length; i++) { + try { + const content = $(scripts[i]).html(); + if (!content) continue; + + const json = JSON.parse(content); + const recipeData = Array.isArray(json) + ? json.find((item) => item['@type'] === 'Recipe') + : json['@type'] === 'Recipe' + ? json + : null; + + if (recipeData) { + return { + title: recipeData.name, + description: recipeData.description, + prepTime: this.parseDuration(recipeData.prepTime), + cookTime: this.parseDuration(recipeData.cookTime), + totalTime: this.parseDuration(recipeData.totalTime), + servings: parseInt(recipeData.recipeYield) || undefined, + imageUrl: this.extractImageUrl(recipeData.image), + author: recipeData.author?.name || recipeData.author, + cuisine: recipeData.recipeCuisine, + category: recipeData.recipeCategory, + rating: recipeData.aggregateRating?.ratingValue, + ingredients: this.parseIngredients(recipeData.recipeIngredient), + instructions: this.parseInstructions(recipeData.recipeInstructions), + }; + } + } catch (error) { + continue; + } + } + + return null; + } + + private extractRecipeFallback($: cheerio.CheerioAPI): Partial { + // Basic fallback extraction + const title = $('h1').first().text().trim() || $('title').text().trim(); + const description = $('meta[name="description"]').attr('content'); + const imageUrl = $('meta[property="og:image"]').attr('content'); + + return { + title, + description, + imageUrl, + ingredients: [], + instructions: [], + }; + } + + private parseDuration(duration?: string): number | undefined { + if (!duration) return undefined; + + // Parse ISO 8601 duration format (PT30M, PT1H30M, etc.) + const matches = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?/); + if (matches) { + const hours = parseInt(matches[1]) || 0; + const minutes = parseInt(matches[2]) || 0; + return hours * 60 + minutes; + } + + return undefined; + } + + private extractImageUrl(image: any): string | undefined { + if (!image) return undefined; + if (typeof image === 'string') return image; + if (Array.isArray(image)) return image[0]; + if (image.url) return image.url; + return undefined; + } + + private parseIngredients(ingredients?: string[]): any[] { + if (!ingredients || !Array.isArray(ingredients)) return []; + + return ingredients.map((ingredient, index) => ({ + name: ingredient, + order: index, + })); + } + + private parseInstructions(instructions?: any): any[] { + if (!instructions) return []; + + if (typeof instructions === 'string') { + return [{ step: 1, text: instructions }]; + } + + if (Array.isArray(instructions)) { + return instructions.map((instruction, index) => { + if (typeof instruction === 'string') { + return { step: index + 1, text: instruction }; + } + if (instruction.text) { + return { step: index + 1, text: instruction.text }; + } + return { step: index + 1, text: JSON.stringify(instruction) }; + }); + } + + return []; + } + + async downloadImage(imageUrl: string): Promise { + const response = await axios.get(imageUrl, { + responseType: 'arraybuffer', + timeout: 10000, + }); + return Buffer.from(response.data); + } +} diff --git a/packages/api/src/services/storage.service.ts b/packages/api/src/services/storage.service.ts new file mode 100644 index 0000000..9f4fb76 --- /dev/null +++ b/packages/api/src/services/storage.service.ts @@ -0,0 +1,66 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { storageConfig } from '../config/storage'; + +export class StorageService { + private static instance: StorageService; + + private constructor() { + this.initialize(); + } + + static getInstance(): StorageService { + if (!StorageService.instance) { + StorageService.instance = new StorageService(); + } + return StorageService.instance; + } + + private async initialize() { + if (storageConfig.type === 'local' && storageConfig.localPath) { + await fs.mkdir(storageConfig.localPath, { recursive: true }); + } + } + + async saveFile(file: Express.Multer.File, folder: string = 'images'): Promise { + if (storageConfig.type === 'local') { + return this.saveFileLocally(file, folder); + } else if (storageConfig.type === 's3') { + return this.saveFileToS3(file, folder); + } + throw new Error('Invalid storage type'); + } + + private async saveFileLocally(file: Express.Multer.File, folder: string): Promise { + const basePath = storageConfig.localPath || './uploads'; + const folderPath = path.join(basePath, folder); + await fs.mkdir(folderPath, { recursive: true }); + + const filename = `${Date.now()}-${file.originalname}`; + const filePath = path.join(folderPath, filename); + + await fs.writeFile(filePath, file.buffer); + + return `/uploads/${folder}/${filename}`; + } + + private async saveFileToS3(_file: Express.Multer.File, _folder: string): Promise { + // TODO: Implement S3 upload using AWS SDK + throw new Error('S3 storage not yet implemented'); + } + + async deleteFile(fileUrl: string): Promise { + if (storageConfig.type === 'local') { + const basePath = storageConfig.localPath || './uploads'; + const filePath = path.join(basePath, fileUrl.replace('/uploads/', '')); + try { + await fs.unlink(filePath); + } catch (error) { + console.error('Error deleting file:', error); + } + } else if (storageConfig.type === 's3') { + // TODO: Implement S3 delete + throw new Error('S3 storage not yet implemented'); + } + } +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..11f658f --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..46b73c4 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "@basil/shared", + "version": "1.0.0", + "description": "Shared TypeScript types and utilities for Basil", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "keywords": ["basil", "shared", "types"], + "license": "MIT", + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..fcb073f --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..cdbbf10 --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,69 @@ +export interface Recipe { + id: string; + title: string; + description?: string; + ingredients: Ingredient[]; + instructions: Instruction[]; + prepTime?: number; // minutes + cookTime?: number; // minutes + totalTime?: number; // minutes + servings?: number; + imageUrl?: string; + images?: string[]; + sourceUrl?: string; // For imported recipes + author?: string; + cuisine?: string; + category?: string; + tags?: string[]; + rating?: number; + createdAt: Date; + updatedAt: Date; +} + +export interface Ingredient { + id?: string; + name: string; + amount?: string; + unit?: string; + notes?: string; + order: number; +} + +export interface Instruction { + id?: string; + step: number; + text: string; + imageUrl?: string; +} + +export interface RecipeImportRequest { + url: string; +} + +export interface RecipeImportResponse { + recipe: Partial; + success: boolean; + error?: string; +} + +export interface StorageConfig { + type: 'local' | 's3'; + localPath?: string; + s3Bucket?: string; + s3Region?: string; + s3AccessKey?: string; + s3SecretKey?: string; +} + +export interface ApiResponse { + data?: T; + error?: string; + message?: string; +} + +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..17085d1 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json new file mode 100644 index 0000000..3fb629f --- /dev/null +++ b/packages/web/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended" + ], + "plugins": ["react-refresh"], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "rules": { + "react-refresh/only-export-components": [ + "warn", + { "allowConstantExport": true } + ] + } +} diff --git a/packages/web/Dockerfile b/packages/web/Dockerfile new file mode 100644 index 0000000..b072989 --- /dev/null +++ b/packages/web/Dockerfile @@ -0,0 +1,33 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy workspace root files +COPY package*.json ./ +COPY packages/shared ./packages/shared +COPY packages/web ./packages/web + +# Install dependencies +RUN npm install + +# Build shared package +WORKDIR /app/packages/shared +RUN npm run build + +# Build web app +WORKDIR /app/packages/web +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files to nginx +COPY --from=builder /app/packages/web/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY packages/web/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/packages/web/index.html b/packages/web/index.html new file mode 100644 index 0000000..6bed12d --- /dev/null +++ b/packages/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Basil - Recipe Manager + + +
+ + + diff --git a/packages/web/nginx.conf b/packages/web/nginx.conf new file mode 100644 index 0000000..182be4f --- /dev/null +++ b/packages/web/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Serve static files + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://basil-api:3001; + 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; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Proxy uploads requests to backend + location /uploads/ { + proxy_pass http://basil-api:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..ae2a1d9 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "@basil/web", + "version": "1.0.0", + "description": "Basil web application", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx" + }, + "keywords": ["basil", "web"], + "license": "MIT", + "dependencies": { + "@basil/shared": "^1.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.1", + "axios": "^1.6.5" + }, + "devDependencies": { + "@types/react": "^18.2.47", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.17.0", + "@typescript-eslint/parser": "^6.17.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } +} diff --git a/packages/web/src/App.css b/packages/web/src/App.css new file mode 100644 index 0000000..1d52278 --- /dev/null +++ b/packages/web/src/App.css @@ -0,0 +1,226 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #f5f5f5; +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; + width: 100%; +} + +.header { + background-color: #2d5016; + color: white; + padding: 1rem 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.header .container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + font-size: 1.5rem; + font-weight: bold; +} + +nav { + display: flex; + gap: 1.5rem; +} + +nav a { + color: white; + text-decoration: none; + font-weight: 500; +} + +nav a:hover { + text-decoration: underline; +} + +.main { + flex: 1; + padding: 2rem 0; +} + +.footer { + background-color: #2d5016; + color: white; + padding: 1rem 0; + text-align: center; + margin-top: auto; +} + +.recipe-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + margin-top: 2rem; +} + +.recipe-card { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + transition: transform 0.2s; + cursor: pointer; +} + +.recipe-card:hover { + transform: translateY(-4px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +.recipe-card img { + width: 100%; + height: 200px; + object-fit: cover; +} + +.recipe-card-content { + padding: 1rem; +} + +.recipe-card h3 { + margin-bottom: 0.5rem; + color: #2d5016; +} + +.recipe-card p { + color: #666; + font-size: 0.9rem; +} + +.recipe-detail { + background: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.recipe-detail img { + width: 100%; + max-height: 400px; + object-fit: cover; + border-radius: 8px; + margin-bottom: 2rem; +} + +.recipe-detail h2 { + color: #2d5016; + margin-bottom: 1rem; +} + +.recipe-meta { + display: flex; + gap: 2rem; + margin-bottom: 2rem; + color: #666; +} + +.ingredients, .instructions { + margin-bottom: 2rem; +} + +.ingredients h3, .instructions h3 { + color: #2d5016; + margin-bottom: 1rem; +} + +.ingredients ul { + list-style: none; + padding: 0; +} + +.ingredients li { + padding: 0.5rem 0; + border-bottom: 1px solid #eee; +} + +.instructions ol { + padding-left: 1.5rem; +} + +.instructions li { + margin-bottom: 1rem; + line-height: 1.6; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.form-group textarea { + min-height: 100px; + resize: vertical; +} + +button { + background-color: #2d5016; + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +button:hover { + background-color: #1f3710; +} + +button:disabled { + background-color: #ccc; + cursor: not-allowed; +} + +.error { + color: #d32f2f; + background-color: #ffebee; + padding: 1rem; + border-radius: 4px; + margin-bottom: 1rem; +} + +.loading { + text-align: center; + padding: 2rem; + color: #666; +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx new file mode 100644 index 0000000..60fb6c4 --- /dev/null +++ b/packages/web/src/App.tsx @@ -0,0 +1,41 @@ +import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; +import RecipeList from './pages/RecipeList'; +import RecipeDetail from './pages/RecipeDetail'; +import RecipeImport from './pages/RecipeImport'; +import './App.css'; + +function App() { + return ( + +
+
+
+

🌿 Basil

+ +
+
+ +
+
+ + } /> + } /> + } /> + +
+
+ +
+
+

Basil - Your Recipe Manager

+
+
+
+
+ ); +} + +export default App; diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx new file mode 100644 index 0000000..9707d82 --- /dev/null +++ b/packages/web/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/packages/web/src/pages/RecipeDetail.tsx b/packages/web/src/pages/RecipeDetail.tsx new file mode 100644 index 0000000..6583832 --- /dev/null +++ b/packages/web/src/pages/RecipeDetail.tsx @@ -0,0 +1,118 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Recipe } from '@basil/shared'; +import { recipesApi } from '../services/api'; + +function RecipeDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [recipe, setRecipe] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (id) { + loadRecipe(id); + } + }, [id]); + + const loadRecipe = async (recipeId: string) => { + try { + setLoading(true); + const response = await recipesApi.getById(recipeId); + setRecipe(response.data || null); + setError(null); + } catch (err) { + setError('Failed to load recipe'); + console.error(err); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (!id || !confirm('Are you sure you want to delete this recipe?')) { + return; + } + + try { + await recipesApi.delete(id); + navigate('/'); + } catch (err) { + setError('Failed to delete recipe'); + console.error(err); + } + }; + + if (loading) { + return
Loading recipe...
; + } + + if (error) { + return
{error}
; + } + + if (!recipe) { + return
Recipe not found
; + } + + return ( +
+ + + + {recipe.imageUrl && {recipe.title}} + +

{recipe.title}

+ + {recipe.description &&

{recipe.description}

} + +
+ {recipe.prepTime && Prep: {recipe.prepTime} min} + {recipe.cookTime && Cook: {recipe.cookTime} min} + {recipe.totalTime && Total: {recipe.totalTime} min} + {recipe.servings && Servings: {recipe.servings}} +
+ + {recipe.sourceUrl && ( +

+ Source: + + {recipe.sourceUrl} + +

+ )} + + {recipe.ingredients && recipe.ingredients.length > 0 && ( +
+

Ingredients

+
    + {recipe.ingredients.map((ingredient, index) => ( +
  • + {ingredient.amount && `${ingredient.amount} `} + {ingredient.unit && `${ingredient.unit} `} + {ingredient.name} + {ingredient.notes && ` (${ingredient.notes})`} +
  • + ))} +
+
+ )} + + {recipe.instructions && recipe.instructions.length > 0 && ( +
+

Instructions

+
    + {recipe.instructions.map((instruction) => ( +
  1. {instruction.text}
  2. + ))} +
+
+ )} +
+ ); +} + +export default RecipeDetail; diff --git a/packages/web/src/pages/RecipeImport.tsx b/packages/web/src/pages/RecipeImport.tsx new file mode 100644 index 0000000..19fd4a9 --- /dev/null +++ b/packages/web/src/pages/RecipeImport.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Recipe } from '@basil/shared'; +import { recipesApi } from '../services/api'; + +function RecipeImport() { + const navigate = useNavigate(); + const [url, setUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [importedRecipe, setImportedRecipe] = useState | null>(null); + + const handleImport = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!url) { + setError('Please enter a URL'); + return; + } + + try { + setLoading(true); + setError(null); + const response = await recipesApi.importFromUrl(url); + + if (response.success && response.recipe) { + setImportedRecipe(response.recipe); + } else { + setError(response.error || 'Failed to import recipe'); + } + } catch (err) { + setError('Failed to import recipe from URL'); + console.error(err); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!importedRecipe) return; + + try { + setLoading(true); + const response = await recipesApi.create(importedRecipe); + if (response.data) { + navigate(`/recipes/${response.data.id}`); + } + } catch (err) { + setError('Failed to save recipe'); + console.error(err); + } finally { + setLoading(false); + } + }; + + return ( +
+

Import Recipe from URL

+ +
+
+ + setUrl(e.target.value)} + placeholder="https://example.com/recipe" + disabled={loading} + /> +
+ + +
+ + {error &&
{error}
} + + {importedRecipe && ( +
+

Imported Recipe Preview

+ + {importedRecipe.imageUrl && ( + {importedRecipe.title} + )} + +

{importedRecipe.title}

+ + {importedRecipe.description &&

{importedRecipe.description}

} + +
+ {importedRecipe.prepTime && Prep: {importedRecipe.prepTime} min} + {importedRecipe.cookTime && Cook: {importedRecipe.cookTime} min} + {importedRecipe.totalTime && Total: {importedRecipe.totalTime} min} + {importedRecipe.servings && Servings: {importedRecipe.servings}} +
+ + {importedRecipe.ingredients && importedRecipe.ingredients.length > 0 && ( +
+

Ingredients

+
    + {importedRecipe.ingredients.map((ingredient, index) => ( +
  • {ingredient.name}
  • + ))} +
+
+ )} + + {importedRecipe.instructions && importedRecipe.instructions.length > 0 && ( +
+

Instructions

+
    + {importedRecipe.instructions.map((instruction) => ( +
  1. {instruction.text}
  2. + ))} +
+
+ )} + + +
+ )} +
+ ); +} + +export default RecipeImport; diff --git a/packages/web/src/pages/RecipeList.tsx b/packages/web/src/pages/RecipeList.tsx new file mode 100644 index 0000000..49e63b5 --- /dev/null +++ b/packages/web/src/pages/RecipeList.tsx @@ -0,0 +1,72 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Recipe } from '@basil/shared'; +import { recipesApi } from '../services/api'; + +function RecipeList() { + const [recipes, setRecipes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + useEffect(() => { + loadRecipes(); + }, []); + + const loadRecipes = async () => { + try { + setLoading(true); + const response = await recipesApi.getAll(); + setRecipes(response.data); + setError(null); + } catch (err) { + setError('Failed to load recipes'); + console.error(err); + } finally { + setLoading(false); + } + }; + + if (loading) { + return
Loading recipes...
; + } + + if (error) { + return
{error}
; + } + + return ( +
+

My Recipes

+ {recipes.length === 0 ? ( +

No recipes yet. Import one from a URL or create your own!

+ ) : ( +
+ {recipes.map((recipe) => ( +
navigate(`/recipes/${recipe.id}`)} + > + {recipe.imageUrl && ( + {recipe.title} + )} +
+

{recipe.title}

+ {recipe.description && ( +

{recipe.description.substring(0, 100)}...

+ )} +
+ {recipe.totalTime && {recipe.totalTime} min} + {recipe.servings && {recipe.servings} servings} +
+
+
+ ))} +
+ )} +
+ ); +} + +export default RecipeList; diff --git a/packages/web/src/services/api.ts b/packages/web/src/services/api.ts new file mode 100644 index 0000000..2a526a1 --- /dev/null +++ b/packages/web/src/services/api.ts @@ -0,0 +1,58 @@ +import axios from 'axios'; +import { Recipe, RecipeImportRequest, RecipeImportResponse, ApiResponse, PaginatedResponse } from '@basil/shared'; + +const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}); + +export const recipesApi = { + getAll: async (params?: { + page?: number; + limit?: number; + search?: string; + cuisine?: string; + category?: string; + }): Promise> => { + const response = await api.get('/recipes', { params }); + return response.data; + }, + + getById: async (id: string): Promise> => { + const response = await api.get(`/recipes/${id}`); + return response.data; + }, + + create: async (recipe: Partial): Promise> => { + const response = await api.post('/recipes', recipe); + return response.data; + }, + + update: async (id: string, recipe: Partial): Promise> => { + const response = await api.put(`/recipes/${id}`, recipe); + return response.data; + }, + + delete: async (id: string): Promise> => { + const response = await api.delete(`/recipes/${id}`); + return response.data; + }, + + uploadImage: async (id: string, file: File): Promise> => { + const formData = new FormData(); + formData.append('image', file); + const response = await api.post(`/recipes/${id}/images`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + return response.data; + }, + + importFromUrl: async (url: string): Promise => { + const response = await api.post('/recipes/import', { url } as RecipeImportRequest); + return response.data; + }, +}; + +export default api; diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/web/tsconfig.node.json b/packages/web/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/packages/web/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/packages/web/vite.config.ts b/packages/web/vite.config.ts new file mode 100644 index 0000000..ccdd31b --- /dev/null +++ b/packages/web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/uploads': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + }, +});