first commit

This commit is contained in:
2025-10-21 22:04:03 -06:00
commit 4e71ef9c66
36 changed files with 2271 additions and 0 deletions

51
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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"
}
}

View 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])
}

View 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;

View 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
View 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}`);
});

View 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;

View 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);
}
}

View 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');
}
}
}

View 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"]
}

View 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"
}
}

View File

@@ -0,0 +1 @@
export * from './types';

View 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;
}

View 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"]
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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>
);

View 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;

View 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;

View 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;

View 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;

View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View 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,
},
},
},
});