feat: add CI/CD pipeline, backup system, and deployment automation
Some checks failed
CI/CD Pipeline / Run Tests (pull_request) Has been cancelled
CI/CD Pipeline / Code Quality (pull_request) Has been cancelled
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Dependency License Check (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
CI/CD Pipeline / Build and Push Docker Images (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled
Some checks failed
CI/CD Pipeline / Run Tests (pull_request) Has been cancelled
CI/CD Pipeline / Code Quality (pull_request) Has been cancelled
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Dependency License Check (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
CI/CD Pipeline / Build and Push Docker Images (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled
## Summary - Add complete CI/CD pipeline with Gitea Actions for automated testing, building, and deployment - Implement backup and restore system with full database and file backup to ZIP - Add deployment automation with webhook receiver and systemd service - Enhance recipe editing UI with improved ingredient parsing and cooking mode features - Add comprehensive documentation for CI/CD, deployment, and backup features ## CI/CD Pipeline - New workflow in .gitea/workflows/ci-cd.yml with test, build, and deploy stages - Automated Docker image building and pushing to registry - Webhook-triggered deployments to production servers ## Backup & Restore - New backup service with ZIP creation including database dump and uploads - REST API endpoints for create, list, download, restore, and delete operations - Configurable backup path via BACKUP_PATH environment variable ## Deployment - Automated deployment scripts (deploy.sh, manual-deploy.sh) - Webhook receiver with systemd service for deployment triggers - Environment configuration template (.env.deploy.example) ## Documentation - docs/CI-CD-SETUP.md - Complete CI/CD pipeline setup guide - docs/DEPLOYMENT-QUICK-START.md - Quick deployment reference - docs/BACKUP.md - Backup and restore documentation - docs/REMOTE_DATABASE.md - Remote database configuration guide - scripts/README.md - Deployment scripts documentation ## Web Improvements - Enhanced ingredient parser with better unit and quantity detection - Improved recipe editing interface with unified edit experience - Better cooking mode functionality - Updated dependencies in package.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
258
packages/api/src/routes/backup.routes.ts
Normal file
258
packages/api/src/routes/backup.routes.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { createBackup, restoreBackup, listBackups, deleteBackup } from '../services/backup.service';
|
||||
import multer from 'multer';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for backup file uploads
|
||||
const upload = multer({
|
||||
dest: '/tmp/basil-restore/',
|
||||
limits: {
|
||||
fileSize: 1024 * 1024 * 1024, // 1GB max
|
||||
},
|
||||
});
|
||||
|
||||
// Get backup directory from env or use default
|
||||
const getBackupDir = (): string => {
|
||||
return process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/backup
|
||||
* Creates a new backup of all data and files
|
||||
*/
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const backupDir = getBackupDir();
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
const backupPath = await createBackup(backupDir);
|
||||
const fileName = path.basename(backupPath);
|
||||
const stats = await fs.stat(backupPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Backup created successfully',
|
||||
backup: {
|
||||
name: fileName,
|
||||
path: backupPath,
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Backup creation error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create backup',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/backup
|
||||
* Lists all available backups
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const backupDir = getBackupDir();
|
||||
const backups = await listBackups(backupDir);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
backups,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing backups:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to list backups',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/backup/:filename
|
||||
* Downloads a specific backup file
|
||||
*/
|
||||
router.get('/:filename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
const backupDir = getBackupDir();
|
||||
const backupPath = path.join(backupDir, filename);
|
||||
|
||||
// Security check: ensure the file is within the backup directory
|
||||
const resolvedPath = path.resolve(backupPath);
|
||||
const resolvedBackupDir = path.resolve(backupDir);
|
||||
if (!resolvedPath.startsWith(resolvedBackupDir)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(backupPath);
|
||||
} catch {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Backup file not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Send file
|
||||
res.download(backupPath, filename, (err) => {
|
||||
if (err) {
|
||||
console.error('Error downloading backup:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to download backup',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error downloading backup:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to download backup',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/backup/restore
|
||||
* Restores data from a backup file
|
||||
* Accepts either:
|
||||
* - multipart/form-data with 'backup' file field
|
||||
* - JSON with 'filename' field (for existing backup in backup directory)
|
||||
*/
|
||||
router.post('/restore', upload.single('backup'), async (req: Request, res: Response) => {
|
||||
let backupPath: string | null = null;
|
||||
let isTemporaryFile = false;
|
||||
|
||||
try {
|
||||
const backupDir = getBackupDir();
|
||||
|
||||
// Check if file was uploaded or filename provided
|
||||
if (req.file) {
|
||||
backupPath = req.file.path;
|
||||
isTemporaryFile = true;
|
||||
} else if (req.body.filename) {
|
||||
backupPath = path.join(backupDir, req.body.filename);
|
||||
|
||||
// Security check
|
||||
const resolvedPath = path.resolve(backupPath);
|
||||
const resolvedBackupDir = path.resolve(backupDir);
|
||||
if (!resolvedPath.startsWith(resolvedBackupDir)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(backupPath);
|
||||
} catch {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Backup file not found',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'No backup file provided. Either upload a file or specify a filename.',
|
||||
});
|
||||
}
|
||||
|
||||
// Perform restore
|
||||
const metadata = await restoreBackup(backupPath, backupDir);
|
||||
|
||||
// Clean up temporary file if it was uploaded
|
||||
if (isTemporaryFile && backupPath) {
|
||||
try {
|
||||
await fs.unlink(backupPath);
|
||||
} catch (err) {
|
||||
console.warn('Failed to clean up temporary file:', err);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Backup restored successfully',
|
||||
metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Restore error:', error);
|
||||
|
||||
// Clean up temporary file on error
|
||||
if (isTemporaryFile && backupPath) {
|
||||
try {
|
||||
await fs.unlink(backupPath);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to restore backup',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/backup/:filename
|
||||
* Deletes a backup file
|
||||
*/
|
||||
router.delete('/:filename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
const backupDir = getBackupDir();
|
||||
const backupPath = path.join(backupDir, filename);
|
||||
|
||||
// Security check
|
||||
const resolvedPath = path.resolve(backupPath);
|
||||
const resolvedBackupDir = path.resolve(backupDir);
|
||||
if (!resolvedPath.startsWith(resolvedBackupDir)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(backupPath);
|
||||
} catch {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Backup file not found',
|
||||
});
|
||||
}
|
||||
|
||||
await deleteBackup(backupPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Backup deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting backup:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete backup',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
437
packages/api/src/services/backup.service.ts
Normal file
437
packages/api/src/services/backup.service.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import archiver from 'archiver';
|
||||
import { createWriteStream, createReadStream } from 'fs';
|
||||
import extract from 'extract-zip';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export interface BackupMetadata {
|
||||
version: string;
|
||||
timestamp: string;
|
||||
recipeCount: number;
|
||||
cookbookCount: number;
|
||||
tagCount: number;
|
||||
}
|
||||
|
||||
export interface BackupData {
|
||||
metadata: BackupMetadata;
|
||||
recipes: any[];
|
||||
cookbooks: any[];
|
||||
tags: any[];
|
||||
recipeTags: any[];
|
||||
cookbookRecipes: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete backup of all database data and uploaded files
|
||||
* Returns the path to the backup file
|
||||
*/
|
||||
export async function createBackup(backupDir: string): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupName = `basil-backup-${timestamp}`;
|
||||
const tempDir = path.join(backupDir, 'temp', backupName);
|
||||
const backupFilePath = path.join(backupDir, `${backupName}.zip`);
|
||||
|
||||
try {
|
||||
// Create temp directory for backup assembly
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Export all database data
|
||||
const backupData = await exportDatabaseData();
|
||||
|
||||
// Write database backup to JSON file
|
||||
const dbBackupPath = path.join(tempDir, 'database.json');
|
||||
await fs.writeFile(dbBackupPath, JSON.stringify(backupData, null, 2));
|
||||
|
||||
// Copy uploaded files
|
||||
const uploadsPath = process.env.LOCAL_STORAGE_PATH || path.join(__dirname, '../../../uploads');
|
||||
const backupUploadsPath = path.join(tempDir, 'uploads');
|
||||
|
||||
try {
|
||||
await fs.access(uploadsPath);
|
||||
await copyDirectory(uploadsPath, backupUploadsPath);
|
||||
} catch (error) {
|
||||
console.warn('No uploads directory found, skipping file backup');
|
||||
}
|
||||
|
||||
// Create ZIP archive
|
||||
await createZipArchive(tempDir, backupFilePath);
|
||||
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
return backupFilePath;
|
||||
} catch (error) {
|
||||
// Clean up on error
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all database data to a structured object
|
||||
*/
|
||||
async function exportDatabaseData(): Promise<BackupData> {
|
||||
// Fetch all data with relations
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
include: {
|
||||
sections: true,
|
||||
ingredients: {
|
||||
include: {
|
||||
instructions: true,
|
||||
},
|
||||
},
|
||||
instructions: {
|
||||
include: {
|
||||
ingredients: true,
|
||||
},
|
||||
},
|
||||
images: true,
|
||||
tags: true,
|
||||
cookbooks: true,
|
||||
},
|
||||
});
|
||||
|
||||
const cookbooks = await prisma.cookbook.findMany({
|
||||
include: {
|
||||
recipes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
include: {
|
||||
recipes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recipeTags = await prisma.recipeTag.findMany();
|
||||
const cookbookRecipes = await prisma.cookbookRecipe.findMany();
|
||||
|
||||
const metadata: BackupMetadata = {
|
||||
version: '1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
recipeCount: recipes.length,
|
||||
cookbookCount: cookbooks.length,
|
||||
tagCount: tags.length,
|
||||
};
|
||||
|
||||
return {
|
||||
metadata,
|
||||
recipes,
|
||||
cookbooks,
|
||||
tags,
|
||||
recipeTags,
|
||||
cookbookRecipes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores database and files from a backup file
|
||||
*/
|
||||
export async function restoreBackup(backupFilePath: string, backupDir: string): Promise<BackupMetadata> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const tempDir = path.join(backupDir, 'temp', `restore-${timestamp}`);
|
||||
|
||||
try {
|
||||
// Extract backup archive
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
await extract(backupFilePath, { dir: tempDir });
|
||||
|
||||
// Read and parse database backup
|
||||
const dbBackupPath = path.join(tempDir, 'database.json');
|
||||
const backupData: BackupData = JSON.parse(await fs.readFile(dbBackupPath, 'utf-8'));
|
||||
|
||||
// Clear existing data (in reverse order of dependencies)
|
||||
await clearDatabase();
|
||||
|
||||
// Restore data (in order of dependencies)
|
||||
await restoreDatabaseData(backupData);
|
||||
|
||||
// Restore uploaded files
|
||||
const backupUploadsPath = path.join(tempDir, 'uploads');
|
||||
const uploadsPath = process.env.LOCAL_STORAGE_PATH || path.join(__dirname, '../../../uploads');
|
||||
|
||||
try {
|
||||
await fs.access(backupUploadsPath);
|
||||
// Clear existing uploads
|
||||
try {
|
||||
await fs.rm(uploadsPath, { recursive: true, force: true });
|
||||
} catch {}
|
||||
await fs.mkdir(uploadsPath, { recursive: true });
|
||||
// Restore uploads
|
||||
await copyDirectory(backupUploadsPath, uploadsPath);
|
||||
} catch (error) {
|
||||
console.warn('No uploads in backup, skipping file restore');
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
|
||||
return backupData.metadata;
|
||||
} catch (error) {
|
||||
// Clean up on error
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all data from the database
|
||||
*/
|
||||
async function clearDatabase(): Promise<void> {
|
||||
// Delete in order to respect foreign key constraints
|
||||
await prisma.cookbookRecipe.deleteMany();
|
||||
await prisma.recipeTag.deleteMany();
|
||||
await prisma.ingredientInstructionMapping.deleteMany();
|
||||
await prisma.recipeImage.deleteMany();
|
||||
await prisma.instruction.deleteMany();
|
||||
await prisma.ingredient.deleteMany();
|
||||
await prisma.recipeSection.deleteMany();
|
||||
await prisma.recipe.deleteMany();
|
||||
await prisma.cookbook.deleteMany();
|
||||
await prisma.tag.deleteMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores database data from backup
|
||||
*/
|
||||
async function restoreDatabaseData(backupData: BackupData): Promise<void> {
|
||||
// Restore tags first (no dependencies)
|
||||
for (const tag of backupData.tags) {
|
||||
await prisma.tag.create({
|
||||
data: {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Restore cookbooks (no dependencies)
|
||||
for (const cookbook of backupData.cookbooks) {
|
||||
await prisma.cookbook.create({
|
||||
data: {
|
||||
id: cookbook.id,
|
||||
name: cookbook.name,
|
||||
description: cookbook.description,
|
||||
coverImageUrl: cookbook.coverImageUrl,
|
||||
autoFilterCategories: cookbook.autoFilterCategories,
|
||||
autoFilterTags: cookbook.autoFilterTags,
|
||||
createdAt: new Date(cookbook.createdAt),
|
||||
updatedAt: new Date(cookbook.updatedAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Restore recipes with all nested relations
|
||||
for (const recipe of backupData.recipes) {
|
||||
await prisma.recipe.create({
|
||||
data: {
|
||||
id: recipe.id,
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
prepTime: recipe.prepTime,
|
||||
cookTime: recipe.cookTime,
|
||||
totalTime: recipe.totalTime,
|
||||
servings: recipe.servings,
|
||||
imageUrl: recipe.imageUrl,
|
||||
sourceUrl: recipe.sourceUrl,
|
||||
author: recipe.author,
|
||||
cuisine: recipe.cuisine,
|
||||
categories: recipe.categories,
|
||||
rating: recipe.rating,
|
||||
createdAt: new Date(recipe.createdAt),
|
||||
updatedAt: new Date(recipe.updatedAt),
|
||||
sections: {
|
||||
create: recipe.sections?.map((section: any) => ({
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
timing: section.timing,
|
||||
})) || [],
|
||||
},
|
||||
ingredients: {
|
||||
create: recipe.ingredients
|
||||
?.filter((ing: any) => !ing.sectionId)
|
||||
.map((ing: any) => ({
|
||||
id: ing.id,
|
||||
name: ing.name,
|
||||
amount: ing.amount,
|
||||
unit: ing.unit,
|
||||
notes: ing.notes,
|
||||
order: ing.order,
|
||||
})) || [],
|
||||
},
|
||||
instructions: {
|
||||
create: recipe.instructions
|
||||
?.filter((inst: any) => !inst.sectionId)
|
||||
.map((inst: any) => ({
|
||||
id: inst.id,
|
||||
step: inst.step,
|
||||
text: inst.text,
|
||||
imageUrl: inst.imageUrl,
|
||||
timing: inst.timing,
|
||||
})) || [],
|
||||
},
|
||||
images: {
|
||||
create: recipe.images?.map((img: any) => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
order: img.order,
|
||||
})) || [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Restore section ingredients and instructions
|
||||
for (const section of recipe.sections || []) {
|
||||
const sectionIngredients = recipe.ingredients?.filter((ing: any) => ing.sectionId === section.id) || [];
|
||||
const sectionInstructions = recipe.instructions?.filter((inst: any) => inst.sectionId === section.id) || [];
|
||||
|
||||
for (const ing of sectionIngredients) {
|
||||
await prisma.ingredient.create({
|
||||
data: {
|
||||
id: ing.id,
|
||||
recipeId: recipe.id,
|
||||
sectionId: section.id,
|
||||
name: ing.name,
|
||||
amount: ing.amount,
|
||||
unit: ing.unit,
|
||||
notes: ing.notes,
|
||||
order: ing.order,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const inst of sectionInstructions) {
|
||||
await prisma.instruction.create({
|
||||
data: {
|
||||
id: inst.id,
|
||||
recipeId: recipe.id,
|
||||
sectionId: section.id,
|
||||
step: inst.step,
|
||||
text: inst.text,
|
||||
imageUrl: inst.imageUrl,
|
||||
timing: inst.timing,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore ingredient-instruction mappings
|
||||
for (const recipe of backupData.recipes) {
|
||||
for (const instruction of recipe.instructions || []) {
|
||||
for (const mapping of instruction.ingredients || []) {
|
||||
await prisma.ingredientInstructionMapping.create({
|
||||
data: {
|
||||
id: mapping.id,
|
||||
ingredientId: mapping.ingredientId,
|
||||
instructionId: mapping.instructionId,
|
||||
order: mapping.order,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore recipe tags
|
||||
for (const recipeTag of backupData.recipeTags) {
|
||||
await prisma.recipeTag.create({
|
||||
data: {
|
||||
recipeId: recipeTag.recipeId,
|
||||
tagId: recipeTag.tagId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Restore cookbook recipes
|
||||
for (const cookbookRecipe of backupData.cookbookRecipes) {
|
||||
await prisma.cookbookRecipe.create({
|
||||
data: {
|
||||
id: cookbookRecipe.id,
|
||||
cookbookId: cookbookRecipe.cookbookId,
|
||||
recipeId: cookbookRecipe.recipeId,
|
||||
addedAt: new Date(cookbookRecipe.addedAt),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ZIP archive from a directory
|
||||
*/
|
||||
async function createZipArchive(sourceDir: string, outputPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(outputPath);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
|
||||
output.on('close', () => resolve());
|
||||
archive.on('error', (err) => reject(err));
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(sourceDir, false);
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copies a directory
|
||||
*/
|
||||
async function copyDirectory(source: string, destination: string): Promise<void> {
|
||||
await fs.mkdir(destination, { recursive: true });
|
||||
const entries = await fs.readdir(source, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(source, entry.name);
|
||||
const destPath = path.join(destination, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectory(srcPath, destPath);
|
||||
} else {
|
||||
await fs.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all available backups in the backup directory
|
||||
*/
|
||||
export async function listBackups(backupDir: string): Promise<Array<{ name: string; path: string; size: number; created: Date }>> {
|
||||
try {
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
const files = await fs.readdir(backupDir);
|
||||
const backups = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith('basil-backup-') && file.endsWith('.zip')) {
|
||||
const filePath = path.join(backupDir, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
backups.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by creation date, newest first
|
||||
return backups.sort((a, b) => b.created.getTime() - a.created.getTime());
|
||||
} catch (error) {
|
||||
console.error('Error listing backups:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a backup file
|
||||
*/
|
||||
export async function deleteBackup(backupFilePath: string): Promise<void> {
|
||||
await fs.unlink(backupFilePath);
|
||||
}
|
||||
@@ -13,32 +13,36 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint . --ext ts,tsx"
|
||||
},
|
||||
"keywords": ["basil", "web"],
|
||||
"keywords": [
|
||||
"basil",
|
||||
"web"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@basil/shared": "^1.0.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"axios": "^1.6.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"axios": "^1.6.5"
|
||||
"react-router-dom": "^6.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.2.0",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@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",
|
||||
"@vitest/coverage-v8": "^1.2.0",
|
||||
"@vitest/ui": "^1.2.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"jsdom": "^23.2.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10",
|
||||
"vitest": "^1.2.0",
|
||||
"@vitest/ui": "^1.2.0",
|
||||
"@vitest/coverage-v8": "^1.2.0",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@testing-library/jest-dom": "^6.2.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"jsdom": "^23.2.0"
|
||||
"vitest": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,13 @@ function CookingMode() {
|
||||
}
|
||||
};
|
||||
|
||||
const scaleServings = (multiplier: number) => {
|
||||
if (recipe?.servings) {
|
||||
const newServings = Math.round(recipe.servings * multiplier);
|
||||
setCurrentServings(newServings > 0 ? newServings : 1);
|
||||
}
|
||||
};
|
||||
|
||||
const getScaledIngredientText = (ingredient: Ingredient): string => {
|
||||
let ingredientStr = '';
|
||||
if (ingredient.amount && ingredient.unit) {
|
||||
@@ -226,13 +233,29 @@ function CookingMode() {
|
||||
<div className="cooking-mode-controls">
|
||||
{recipe.servings && currentServings !== null && (
|
||||
<div className="servings-control">
|
||||
<button onClick={decrementServings} disabled={currentServings <= 1}>
|
||||
−
|
||||
</button>
|
||||
<span>Servings: {currentServings}</span>
|
||||
<button onClick={incrementServings}>
|
||||
+
|
||||
</button>
|
||||
<div className="servings-adjuster">
|
||||
<button onClick={decrementServings} disabled={currentServings <= 1}>
|
||||
−
|
||||
</button>
|
||||
<span>Servings: {currentServings}</span>
|
||||
<button onClick={incrementServings}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="quick-scale-buttons">
|
||||
<button onClick={() => scaleServings(0.5)} className="scale-button" title="Half recipe">
|
||||
½×
|
||||
</button>
|
||||
<button onClick={() => scaleServings(1.5)} className="scale-button" title="1.5× recipe">
|
||||
1.5×
|
||||
</button>
|
||||
<button onClick={() => scaleServings(2)} className="scale-button" title="Double recipe">
|
||||
2×
|
||||
</button>
|
||||
<button onClick={() => scaleServings(3)} className="scale-button" title="Triple recipe">
|
||||
3×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -53,6 +53,13 @@ function RecipeDetail() {
|
||||
setCurrentServings(recipe?.servings || null);
|
||||
};
|
||||
|
||||
const scaleServings = (multiplier: number) => {
|
||||
if (recipe?.servings) {
|
||||
const newServings = Math.round(recipe.servings * multiplier);
|
||||
setCurrentServings(newServings > 0 ? newServings : 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id || !confirm('Are you sure you want to delete this recipe?')) {
|
||||
return;
|
||||
@@ -140,18 +147,34 @@ function RecipeDetail() {
|
||||
{recipe.totalTime && <span>Total: {recipe.totalTime} min</span>}
|
||||
{recipe.servings && currentServings !== null && (
|
||||
<div className="servings-control">
|
||||
<button onClick={decrementServings} disabled={currentServings <= 1}>
|
||||
−
|
||||
</button>
|
||||
<span>Servings: {currentServings}</span>
|
||||
<button onClick={incrementServings}>
|
||||
+
|
||||
</button>
|
||||
{currentServings !== recipe.servings && (
|
||||
<button onClick={resetServings} className="reset-button">
|
||||
Reset
|
||||
<div className="servings-adjuster">
|
||||
<button onClick={decrementServings} disabled={currentServings <= 1}>
|
||||
−
|
||||
</button>
|
||||
)}
|
||||
<span>Servings: {currentServings}</span>
|
||||
<button onClick={incrementServings}>
|
||||
+
|
||||
</button>
|
||||
{currentServings !== recipe.servings && (
|
||||
<button onClick={resetServings} className="reset-button">
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="quick-scale-buttons">
|
||||
<button onClick={() => scaleServings(0.5)} className="scale-button" title="Half recipe">
|
||||
½×
|
||||
</button>
|
||||
<button onClick={() => scaleServings(1.5)} className="scale-button" title="1.5× recipe">
|
||||
1.5×
|
||||
</button>
|
||||
<button onClick={() => scaleServings(2)} className="scale-button" title="Double recipe">
|
||||
2×
|
||||
</button>
|
||||
<button onClick={() => scaleServings(3)} className="scale-button" title="Triple recipe">
|
||||
3×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Recipe, RecipeSection, Ingredient, Instruction } from '@basil/shared';
|
||||
import { recipesApi } from '../services/api';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||
|
||||
interface RecipeFormProps {
|
||||
initialRecipe?: Partial<Recipe>;
|
||||
@@ -147,6 +148,20 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
const reorderSectionInstructions = (sectionIndex: number, result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const newSections = [...sections];
|
||||
const items = Array.from(newSections[sectionIndex].instructions);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
// Update step numbers
|
||||
const updatedItems = items.map((item, index) => ({ ...item, step: index + 1 }));
|
||||
newSections[sectionIndex].instructions = updatedItems;
|
||||
setSections(newSections);
|
||||
};
|
||||
|
||||
// Simple mode ingredient management
|
||||
const addIngredient = () => {
|
||||
setIngredients([
|
||||
@@ -185,6 +200,18 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
setInstructions(newInstructions);
|
||||
};
|
||||
|
||||
const reorderInstructions = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(instructions);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
// Update step numbers
|
||||
const updatedItems = items.map((item, index) => ({ ...item, step: index + 1 }));
|
||||
setInstructions(updatedItems);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !initialRecipe?.id) return;
|
||||
@@ -573,49 +600,77 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
{/* Section Instructions */}
|
||||
<div className="subsection">
|
||||
<h5>Instructions</h5>
|
||||
{section.instructions.map((instruction, instructionIndex) => (
|
||||
<div key={instructionIndex} className="instruction-row">
|
||||
<div className="instruction-number">{instruction.step}</div>
|
||||
<div className="instruction-content">
|
||||
<input
|
||||
type="text"
|
||||
value={instruction.timing}
|
||||
onChange={(e) =>
|
||||
updateSectionInstruction(
|
||||
sectionIndex,
|
||||
instructionIndex,
|
||||
'timing',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Timing (optional, e.g., 8:00am)"
|
||||
className="instruction-timing-input"
|
||||
/>
|
||||
<textarea
|
||||
value={instruction.text}
|
||||
onChange={(e) =>
|
||||
updateSectionInstruction(
|
||||
sectionIndex,
|
||||
instructionIndex,
|
||||
'text',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Instruction text *"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{section.instructions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSectionInstruction(sectionIndex, instructionIndex)}
|
||||
className="btn-remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<DragDropContext onDragEnd={(result) => reorderSectionInstructions(sectionIndex, result)}>
|
||||
<Droppable droppableId={`section-${sectionIndex}-instructions`}>
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{section.instructions.map((instruction, instructionIndex) => (
|
||||
<Draggable
|
||||
key={`section-${sectionIndex}-instruction-${instructionIndex}`}
|
||||
draggableId={`section-${sectionIndex}-instruction-${instructionIndex}`}
|
||||
index={instructionIndex}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`instruction-row ${snapshot.isDragging ? 'dragging' : ''}`}
|
||||
>
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="instruction-drag-handle"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⋮⋮
|
||||
</div>
|
||||
<div className="instruction-number">{instruction.step}</div>
|
||||
<div className="instruction-content">
|
||||
<input
|
||||
type="text"
|
||||
value={instruction.timing}
|
||||
onChange={(e) =>
|
||||
updateSectionInstruction(
|
||||
sectionIndex,
|
||||
instructionIndex,
|
||||
'timing',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Timing (optional, e.g., 8:00am)"
|
||||
className="instruction-timing-input"
|
||||
/>
|
||||
<textarea
|
||||
value={instruction.text}
|
||||
onChange={(e) =>
|
||||
updateSectionInstruction(
|
||||
sectionIndex,
|
||||
instructionIndex,
|
||||
'text',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Instruction text *"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{section.instructions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeSectionInstruction(sectionIndex, instructionIndex)}
|
||||
className="btn-remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addSectionInstruction(sectionIndex)}
|
||||
@@ -676,35 +731,63 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
{/* Instructions */}
|
||||
<div className="form-section">
|
||||
<h3>Instructions</h3>
|
||||
{instructions.map((instruction, index) => (
|
||||
<div key={index} className="instruction-row">
|
||||
<div className="instruction-number">{instruction.step}</div>
|
||||
<div className="instruction-content">
|
||||
<input
|
||||
type="text"
|
||||
value={instruction.timing}
|
||||
onChange={(e) => updateInstruction(index, 'timing', e.target.value)}
|
||||
placeholder="Timing (optional, e.g., 8:00am)"
|
||||
className="instruction-timing-input"
|
||||
/>
|
||||
<textarea
|
||||
value={instruction.text}
|
||||
onChange={(e) => updateInstruction(index, 'text', e.target.value)}
|
||||
placeholder="Instruction text *"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{instructions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeInstruction(index)}
|
||||
className="btn-remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<DragDropContext onDragEnd={reorderInstructions}>
|
||||
<Droppable droppableId="instructions">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{instructions.map((instruction, index) => (
|
||||
<Draggable
|
||||
key={`instruction-${index}`}
|
||||
draggableId={`instruction-${index}`}
|
||||
index={index}
|
||||
>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`instruction-row ${snapshot.isDragging ? 'dragging' : ''}`}
|
||||
>
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="instruction-drag-handle"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⋮⋮
|
||||
</div>
|
||||
<div className="instruction-number">{instruction.step}</div>
|
||||
<div className="instruction-content">
|
||||
<input
|
||||
type="text"
|
||||
value={instruction.timing}
|
||||
onChange={(e) => updateInstruction(index, 'timing', e.target.value)}
|
||||
placeholder="Timing (optional, e.g., 8:00am)"
|
||||
className="instruction-timing-input"
|
||||
/>
|
||||
<textarea
|
||||
value={instruction.text}
|
||||
onChange={(e) => updateInstruction(index, 'text', e.target.value)}
|
||||
placeholder="Instruction text *"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{instructions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeInstruction(index)}
|
||||
className="btn-remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<button type="button" onClick={addInstruction} className="btn-secondary">
|
||||
+ Add Instruction
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Recipe, Ingredient, Instruction, RecipeSection, Tag } from '@basil/shared';
|
||||
import { recipesApi, tagsApi } from '../services/api';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd';
|
||||
import '../styles/UnifiedRecipeEdit.css';
|
||||
|
||||
interface MappingChange {
|
||||
@@ -92,7 +93,10 @@ function UnifiedEditRecipe() {
|
||||
setServings(loadedRecipe.servings?.toString() || '');
|
||||
setCuisine(loadedRecipe.cuisine || '');
|
||||
setRecipeCategories(loadedRecipe.categories || []);
|
||||
setRecipeTags(loadedRecipe.tags || []);
|
||||
|
||||
// Handle tags - API returns array of {tag: {id, name}} objects, we need string[]
|
||||
const tagNames = (loadedRecipe.tags as any)?.map((t: any) => t.tag?.name || t).filter(Boolean) || [];
|
||||
setRecipeTags(tagNames);
|
||||
|
||||
// Set sections or simple mode
|
||||
const hasSections = !!(loadedRecipe.sections && loadedRecipe.sections.length > 0);
|
||||
@@ -301,6 +305,19 @@ function UnifiedEditRecipe() {
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const reorderInstructions = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(instructions);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
// Update step numbers
|
||||
const updatedItems = items.map((item, index) => ({ ...item, step: index + 1 }));
|
||||
setInstructions(updatedItems);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
// Drag and drop
|
||||
const handleIngredientDragStart = (ingredient: Ingredient) => {
|
||||
setDraggedIngredient(ingredient);
|
||||
@@ -1099,132 +1116,161 @@ function UnifiedEditRecipe() {
|
||||
<div className="instructions-panel">
|
||||
<h3>Instructions</h3>
|
||||
|
||||
<ul className="instructions-list">
|
||||
{allInstructions.map((instruction, index) => {
|
||||
const isEditing = editingInstructionId === instruction.id;
|
||||
const mappedIngredients = getMappedIngredientsForInstruction(
|
||||
instruction.id || ''
|
||||
);
|
||||
const isDragOver = dragOverInstructionId === instruction.id;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={instruction.id || index}
|
||||
className={`instruction-item ${isDragOver ? 'drag-over' : ''}`}
|
||||
onDragOver={(e) => handleInstructionDragOver(e, instruction.id || '')}
|
||||
onDragLeave={handleInstructionDragLeave}
|
||||
onDrop={(e) => handleInstructionDrop(e, instruction.id || '')}
|
||||
<DragDropContext onDragEnd={reorderInstructions}>
|
||||
<Droppable droppableId="instructions">
|
||||
{(provided) => (
|
||||
<ul
|
||||
className="instructions-list"
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
<div className="instruction-header">
|
||||
<span className="step-number">Step {instruction.step}</span>
|
||||
{allInstructions.map((instruction, index) => {
|
||||
const isEditing = editingInstructionId === instruction.id;
|
||||
const mappedIngredients = getMappedIngredientsForInstruction(
|
||||
instruction.id || ''
|
||||
);
|
||||
const isDragOver = dragOverInstructionId === instruction.id;
|
||||
|
||||
{!isEditing && (
|
||||
<div className="instruction-controls">
|
||||
<button
|
||||
className="btn-edit-instruction"
|
||||
onClick={() => startEditingInstruction(instruction)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn-delete-instruction"
|
||||
onClick={() => removeInstruction(index)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="instruction-timing-input"
|
||||
value={editingInstructionTiming}
|
||||
onChange={(e) => setEditingInstructionTiming(e.target.value)}
|
||||
placeholder="Timing (optional, e.g., 8:00am)"
|
||||
/>
|
||||
<textarea
|
||||
className="instruction-text-input"
|
||||
value={editingInstructionText}
|
||||
onChange={(e) => setEditingInstructionText(e.target.value)}
|
||||
placeholder="Instruction text"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="instruction-edit-actions">
|
||||
<button
|
||||
className="btn-save-instruction"
|
||||
onClick={saveEditingInstruction}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="btn-cancel-instruction"
|
||||
onClick={cancelEditingInstruction}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{instruction.timing && (
|
||||
<div className="instruction-timing-display">
|
||||
{instruction.timing}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="instruction-text-display"
|
||||
onClick={() => startEditingInstruction(instruction)}
|
||||
title="Click to edit"
|
||||
return (
|
||||
<Draggable
|
||||
key={instruction.id || `instruction-${index}`}
|
||||
draggableId={instruction.id || `instruction-${index}`}
|
||||
index={index}
|
||||
>
|
||||
{instruction.text || <em>Click to add instruction text</em>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drop zone for ingredients */}
|
||||
<div className="drop-zone">
|
||||
<span className="drop-zone-header">
|
||||
Ingredients for this step:
|
||||
</span>
|
||||
|
||||
{mappedIngredients.length === 0 ? (
|
||||
<p className="no-ingredients-mapped">
|
||||
Drag ingredients here or use bulk actions
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mapped-ingredients-list">
|
||||
{mappedIngredients.map((ingredient) => (
|
||||
{(provided, snapshot) => (
|
||||
<li
|
||||
key={ingredient.id}
|
||||
className="mapped-ingredient-item"
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`instruction-item ${isDragOver ? 'drag-over' : ''} ${snapshot.isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={(e) => handleInstructionDragOver(e, instruction.id || '')}
|
||||
onDragLeave={handleInstructionDragLeave}
|
||||
onDrop={(e) => handleInstructionDrop(e, instruction.id || '')}
|
||||
>
|
||||
<span className="mapped-ingredient-text">
|
||||
{getIngredientText(ingredient)}
|
||||
</span>
|
||||
<button
|
||||
className="btn-remove-ingredient"
|
||||
onClick={() =>
|
||||
removeIngredientFromInstruction(
|
||||
ingredient.id || '',
|
||||
instruction.id || ''
|
||||
)
|
||||
}
|
||||
title="Remove ingredient from this step"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="instruction-header">
|
||||
<div className="instruction-header-left">
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
className="instruction-drag-handle"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
⋮⋮
|
||||
</div>
|
||||
<span className="step-number">Step {instruction.step}</span>
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="instruction-controls">
|
||||
<button
|
||||
className="btn-edit-instruction"
|
||||
onClick={() => startEditingInstruction(instruction)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn-delete-instruction"
|
||||
onClick={() => removeInstruction(index)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="instruction-timing-input"
|
||||
value={editingInstructionTiming}
|
||||
onChange={(e) => setEditingInstructionTiming(e.target.value)}
|
||||
placeholder="Timing (optional, e.g., 8:00am)"
|
||||
/>
|
||||
<textarea
|
||||
className="instruction-text-input"
|
||||
value={editingInstructionText}
|
||||
onChange={(e) => setEditingInstructionText(e.target.value)}
|
||||
placeholder="Instruction text"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="instruction-edit-actions">
|
||||
<button
|
||||
className="btn-save-instruction"
|
||||
onClick={saveEditingInstruction}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="btn-cancel-instruction"
|
||||
onClick={cancelEditingInstruction}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{instruction.timing && (
|
||||
<div className="instruction-timing-display">
|
||||
{instruction.timing}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="instruction-text-display"
|
||||
onClick={() => startEditingInstruction(instruction)}
|
||||
title="Click to edit"
|
||||
>
|
||||
{instruction.text || <em>Click to add instruction text</em>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Drop zone for ingredients */}
|
||||
<div className="drop-zone">
|
||||
<span className="drop-zone-header">
|
||||
Ingredients for this step:
|
||||
</span>
|
||||
|
||||
{mappedIngredients.length === 0 ? (
|
||||
<p className="no-ingredients-mapped">
|
||||
Drag ingredients here or use bulk actions
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mapped-ingredients-list">
|
||||
{mappedIngredients.map((ingredient) => (
|
||||
<li
|
||||
key={ingredient.id}
|
||||
className="mapped-ingredient-item"
|
||||
>
|
||||
<span className="mapped-ingredient-text">
|
||||
{getIngredientText(ingredient)}
|
||||
</span>
|
||||
<button
|
||||
className="btn-remove-ingredient"
|
||||
onClick={() =>
|
||||
removeIngredientFromInstruction(
|
||||
ingredient.id || '',
|
||||
instruction.id || ''
|
||||
)
|
||||
}
|
||||
title="Remove ingredient from this step"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</ul>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
|
||||
<button className="btn-add-instruction" onClick={addInstruction}>
|
||||
+ Add Instruction
|
||||
|
||||
@@ -525,6 +525,14 @@
|
||||
box-shadow: 0 4px 12px rgba(46, 125, 50, 0.2);
|
||||
}
|
||||
|
||||
.instruction-item.dragging {
|
||||
opacity: 0.6;
|
||||
background-color: #f5f5f5;
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
border-color: #1976d2;
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.instruction-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -532,6 +540,31 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.instruction-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.instruction-drag-handle {
|
||||
cursor: grab;
|
||||
color: #999;
|
||||
font-size: 1.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem;
|
||||
user-select: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.instruction-drag-handle:hover {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.instruction-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -84,11 +84,14 @@ function fractionToDecimal(fraction: string): number {
|
||||
function parseAmount(amountStr: string): { value: number | null; range: { min: number; max: number } | null } {
|
||||
amountStr = amountStr.trim();
|
||||
|
||||
// Replace unicode fractions
|
||||
// Replace unicode fractions with decimal equivalents
|
||||
for (const [unicode, decimal] of Object.entries(UNICODE_FRACTIONS)) {
|
||||
amountStr = amountStr.replace(unicode, ` ${decimal}`);
|
||||
}
|
||||
|
||||
// Clean up extra whitespace that might have been introduced
|
||||
amountStr = amountStr.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Handle ranges: "2-3", "1 to 2", "1-2"
|
||||
const rangeMatch = amountStr.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*(\d+(?:\.\d+)?)$/i);
|
||||
if (rangeMatch) {
|
||||
@@ -97,7 +100,7 @@ function parseAmount(amountStr: string): { value: number | null; range: { min: n
|
||||
return { value: null, range: { min, max } };
|
||||
}
|
||||
|
||||
// Handle mixed numbers: "1 1/2", "2 3/4"
|
||||
// Handle mixed numbers: "1 1/2", "2 3/4", "1 1/2" (with any amount of whitespace)
|
||||
const mixedMatch = amountStr.match(/^(\d+)\s+(\d+)\/(\d+)$/);
|
||||
if (mixedMatch) {
|
||||
const whole = parseFloat(mixedMatch[1]);
|
||||
@@ -105,6 +108,18 @@ function parseAmount(amountStr: string): { value: number | null; range: { min: n
|
||||
return { value: whole + fraction, range: null };
|
||||
}
|
||||
|
||||
// Also try to handle space-separated numbers that might be part of decimal representation
|
||||
// e.g., "2 0.25" should be treated as "2.25"
|
||||
const spaceDecimalMatch = amountStr.match(/^(\d+)\s+(\d+(?:\.\d+)?)$/);
|
||||
if (spaceDecimalMatch) {
|
||||
const whole = parseFloat(spaceDecimalMatch[1]);
|
||||
const decimal = parseFloat(spaceDecimalMatch[2]);
|
||||
// Only treat as addition if decimal part is < 1 (otherwise it's likely separate numbers)
|
||||
if (decimal < 1) {
|
||||
return { value: whole + decimal, range: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle simple fractions: "1/2", "3/4"
|
||||
if (amountStr.includes('/')) {
|
||||
return { value: fractionToDecimal(amountStr), range: null };
|
||||
@@ -125,15 +140,16 @@ function parseAmount(amountStr: string): { value: number | null; range: { min: n
|
||||
export function parseIngredient(ingredientStr: string): ParsedIngredient {
|
||||
const original = ingredientStr;
|
||||
|
||||
// Check for non-scalable patterns
|
||||
const nonScalablePatterns = [
|
||||
/to taste/i,
|
||||
/as needed/i,
|
||||
/for (?:serving|garnish|dusting)/i,
|
||||
/optional/i,
|
||||
// Check for non-scalable patterns at the START of the ingredient
|
||||
// These patterns should only make it non-scalable if they appear early in the string
|
||||
// Not if they're notes at the end like "2 cups flour, plus more as needed"
|
||||
const startNonScalablePatterns = [
|
||||
/^to taste/i,
|
||||
/^optional/i,
|
||||
/^for (?:serving|garnish|dusting)/i,
|
||||
];
|
||||
|
||||
const isNonScalable = nonScalablePatterns.some(pattern => pattern.test(ingredientStr));
|
||||
const isNonScalable = startNonScalablePatterns.some(pattern => pattern.test(ingredientStr));
|
||||
|
||||
if (isNonScalable) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user