first commit
This commit is contained in:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -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/
|
||||
202
CLAUDE.md
Normal file
202
CLAUDE.md
Normal file
@@ -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
|
||||
229
README.md
Normal file
229
README.md
Normal file
@@ -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 <repository-url>
|
||||
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.
|
||||
56
docker-compose.yml
Normal file
56
docker-compose.yml
Normal file
@@ -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:
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -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"
|
||||
}
|
||||
20
packages/api/.env.example
Normal file
20
packages/api/.env.example
Normal file
@@ -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
|
||||
20
packages/api/.eslintrc.json
Normal file
20
packages/api/.eslintrc.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
45
packages/api/Dockerfile
Normal file
45
packages/api/Dockerfile
Normal file
@@ -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"]
|
||||
40
packages/api/package.json
Normal file
40
packages/api/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
90
packages/api/prisma/schema.prisma
Normal file
90
packages/api/prisma/schema.prisma
Normal file
@@ -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])
|
||||
}
|
||||
7
packages/api/src/config/database.ts
Normal file
7
packages/api/src/config/database.ts
Normal file
@@ -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;
|
||||
10
packages/api/src/config/storage.ts
Normal file
10
packages/api/src/config/storage.ts
Normal file
@@ -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,
|
||||
};
|
||||
33
packages/api/src/index.ts
Normal file
33
packages/api/src/index.ts
Normal file
@@ -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}`);
|
||||
});
|
||||
256
packages/api/src/routes/recipes.routes.ts
Normal file
256
packages/api/src/routes/recipes.routes.ts
Normal file
@@ -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;
|
||||
166
packages/api/src/services/scraper.service.ts
Normal file
166
packages/api/src/services/scraper.service.ts
Normal file
@@ -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<RecipeImportResponse> {
|
||||
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<Recipe> | 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<Recipe> {
|
||||
// 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<Buffer> {
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 10000,
|
||||
});
|
||||
return Buffer.from(response.data);
|
||||
}
|
||||
}
|
||||
66
packages/api/src/services/storage.service.ts
Normal file
66
packages/api/src/services/storage.service.ts
Normal file
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
// TODO: Implement S3 upload using AWS SDK
|
||||
throw new Error('S3 storage not yet implemented');
|
||||
}
|
||||
|
||||
async deleteFile(fileUrl: string): Promise<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/api/tsconfig.json
Normal file
18
packages/api/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
16
packages/shared/package.json
Normal file
16
packages/shared/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1
packages/shared/src/index.ts
Normal file
1
packages/shared/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './types';
|
||||
69
packages/shared/src/types.ts
Normal file
69
packages/shared/src/types.ts
Normal file
@@ -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<Recipe>;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
type: 'local' | 's3';
|
||||
localPath?: string;
|
||||
s3Bucket?: string;
|
||||
s3Region?: string;
|
||||
s3AccessKey?: string;
|
||||
s3SecretKey?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
17
packages/shared/tsconfig.json
Normal file
17
packages/shared/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
19
packages/web/.eslintrc.json
Normal file
19
packages/web/.eslintrc.json
Normal file
@@ -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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
33
packages/web/Dockerfile
Normal file
33
packages/web/Dockerfile
Normal file
@@ -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;"]
|
||||
13
packages/web/index.html
Normal file
13
packages/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/basil.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Basil - Recipe Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
packages/web/nginx.conf
Normal file
32
packages/web/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
33
packages/web/package.json
Normal file
33
packages/web/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
226
packages/web/src/App.css
Normal file
226
packages/web/src/App.css
Normal file
@@ -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;
|
||||
}
|
||||
41
packages/web/src/App.tsx
Normal file
41
packages/web/src/App.tsx
Normal file
@@ -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 (
|
||||
<Router>
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<h1 className="logo">🌿 Basil</h1>
|
||||
<nav>
|
||||
<Link to="/">Recipes</Link>
|
||||
<Link to="/import">Import Recipe</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="main">
|
||||
<div className="container">
|
||||
<Routes>
|
||||
<Route path="/" element={<RecipeList />} />
|
||||
<Route path="/recipes/:id" element={<RecipeDetail />} />
|
||||
<Route path="/import" element={<RecipeImport />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<p>Basil - Your Recipe Manager</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
9
packages/web/src/main.tsx
Normal file
9
packages/web/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
118
packages/web/src/pages/RecipeDetail.tsx
Normal file
118
packages/web/src/pages/RecipeDetail.tsx
Normal file
@@ -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<Recipe | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <div className="loading">Loading recipe...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
if (!recipe) {
|
||||
return <div className="error">Recipe not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipe-detail">
|
||||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||||
<button onClick={handleDelete} style={{ marginLeft: '1rem', backgroundColor: '#d32f2f' }}>
|
||||
Delete Recipe
|
||||
</button>
|
||||
|
||||
{recipe.imageUrl && <img src={recipe.imageUrl} alt={recipe.title} />}
|
||||
|
||||
<h2>{recipe.title}</h2>
|
||||
|
||||
{recipe.description && <p>{recipe.description}</p>}
|
||||
|
||||
<div className="recipe-meta">
|
||||
{recipe.prepTime && <span>Prep: {recipe.prepTime} min</span>}
|
||||
{recipe.cookTime && <span>Cook: {recipe.cookTime} min</span>}
|
||||
{recipe.totalTime && <span>Total: {recipe.totalTime} min</span>}
|
||||
{recipe.servings && <span>Servings: {recipe.servings}</span>}
|
||||
</div>
|
||||
|
||||
{recipe.sourceUrl && (
|
||||
<p>
|
||||
<strong>Source: </strong>
|
||||
<a href={recipe.sourceUrl} target="_blank" rel="noopener noreferrer">
|
||||
{recipe.sourceUrl}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{recipe.ingredients && recipe.ingredients.length > 0 && (
|
||||
<div className="ingredients">
|
||||
<h3>Ingredients</h3>
|
||||
<ul>
|
||||
{recipe.ingredients.map((ingredient, index) => (
|
||||
<li key={index}>
|
||||
{ingredient.amount && `${ingredient.amount} `}
|
||||
{ingredient.unit && `${ingredient.unit} `}
|
||||
{ingredient.name}
|
||||
{ingredient.notes && ` (${ingredient.notes})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.instructions && recipe.instructions.length > 0 && (
|
||||
<div className="instructions">
|
||||
<h3>Instructions</h3>
|
||||
<ol>
|
||||
{recipe.instructions.map((instruction) => (
|
||||
<li key={instruction.step}>{instruction.text}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecipeDetail;
|
||||
130
packages/web/src/pages/RecipeImport.tsx
Normal file
130
packages/web/src/pages/RecipeImport.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [importedRecipe, setImportedRecipe] = useState<Partial<Recipe> | 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 (
|
||||
<div>
|
||||
<h2>Import Recipe from URL</h2>
|
||||
|
||||
<form onSubmit={handleImport}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="url">Recipe URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/recipe"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? 'Importing...' : 'Import Recipe'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
{importedRecipe && (
|
||||
<div className="recipe-detail" style={{ marginTop: '2rem' }}>
|
||||
<h3>Imported Recipe Preview</h3>
|
||||
|
||||
{importedRecipe.imageUrl && (
|
||||
<img src={importedRecipe.imageUrl} alt={importedRecipe.title} />
|
||||
)}
|
||||
|
||||
<h2>{importedRecipe.title}</h2>
|
||||
|
||||
{importedRecipe.description && <p>{importedRecipe.description}</p>}
|
||||
|
||||
<div className="recipe-meta">
|
||||
{importedRecipe.prepTime && <span>Prep: {importedRecipe.prepTime} min</span>}
|
||||
{importedRecipe.cookTime && <span>Cook: {importedRecipe.cookTime} min</span>}
|
||||
{importedRecipe.totalTime && <span>Total: {importedRecipe.totalTime} min</span>}
|
||||
{importedRecipe.servings && <span>Servings: {importedRecipe.servings}</span>}
|
||||
</div>
|
||||
|
||||
{importedRecipe.ingredients && importedRecipe.ingredients.length > 0 && (
|
||||
<div className="ingredients">
|
||||
<h3>Ingredients</h3>
|
||||
<ul>
|
||||
{importedRecipe.ingredients.map((ingredient, index) => (
|
||||
<li key={index}>{ingredient.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importedRecipe.instructions && importedRecipe.instructions.length > 0 && (
|
||||
<div className="instructions">
|
||||
<h3>Instructions</h3>
|
||||
<ol>
|
||||
{importedRecipe.instructions.map((instruction) => (
|
||||
<li key={instruction.step}>{instruction.text}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleSave} disabled={loading}>
|
||||
Save Recipe
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecipeImport;
|
||||
72
packages/web/src/pages/RecipeList.tsx
Normal file
72
packages/web/src/pages/RecipeList.tsx
Normal file
@@ -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<Recipe[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <div className="loading">Loading recipes...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>My Recipes</h2>
|
||||
{recipes.length === 0 ? (
|
||||
<p>No recipes yet. Import one from a URL or create your own!</p>
|
||||
) : (
|
||||
<div className="recipe-grid">
|
||||
{recipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
className="recipe-card"
|
||||
onClick={() => navigate(`/recipes/${recipe.id}`)}
|
||||
>
|
||||
{recipe.imageUrl && (
|
||||
<img src={recipe.imageUrl} alt={recipe.title} />
|
||||
)}
|
||||
<div className="recipe-card-content">
|
||||
<h3>{recipe.title}</h3>
|
||||
{recipe.description && (
|
||||
<p>{recipe.description.substring(0, 100)}...</p>
|
||||
)}
|
||||
<div className="recipe-meta">
|
||||
{recipe.totalTime && <span>{recipe.totalTime} min</span>}
|
||||
{recipe.servings && <span>{recipe.servings} servings</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecipeList;
|
||||
58
packages/web/src/services/api.ts
Normal file
58
packages/web/src/services/api.ts
Normal file
@@ -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<PaginatedResponse<Recipe>> => {
|
||||
const response = await api.get('/recipes', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ApiResponse<Recipe>> => {
|
||||
const response = await api.get(`/recipes/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (recipe: Partial<Recipe>): Promise<ApiResponse<Recipe>> => {
|
||||
const response = await api.post('/recipes', recipe);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, recipe: Partial<Recipe>): Promise<ApiResponse<Recipe>> => {
|
||||
const response = await api.put(`/recipes/${id}`, recipe);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.delete(`/recipes/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadImage: async (id: string, file: File): Promise<ApiResponse<{ url: string }>> => {
|
||||
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<RecipeImportResponse> => {
|
||||
const response = await api.post('/recipes/import', { url } as RecipeImportRequest);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
25
packages/web/tsconfig.json
Normal file
25
packages/web/tsconfig.json
Normal file
@@ -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" }]
|
||||
}
|
||||
10
packages/web/tsconfig.node.json
Normal file
10
packages/web/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
packages/web/vite.config.ts
Normal file
19
packages/web/vite.config.ts
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user