feat: add cookbooks, multiple categories, and image management
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
Major features added: - Cookbook management with CRUD operations - Auto-filter cookbooks by categories and tags - Multiple categories per recipe (changed from single category) - Image upload and URL download for cookbooks - Improved image management UI Database changes: - Changed Recipe.category (string) to Recipe.categories (string array) - Added Cookbook and CookbookRecipe models - Added Tag and RecipeTag models for recipe tagging Backend changes: - Added cookbooks API routes with image upload - Added tags API routes - Added auto-filter functionality to add recipes to cookbooks automatically - Added downloadAndSaveImage() to StorageService for URL downloads - Updated recipes routes to support multiple categories Frontend changes: - Added Cookbooks page with grid view - Added CookbookDetail page with filtering - Added EditCookbook page with image upload/download - Updated recipe forms to use chip-based UI for multiple categories - Improved image upload UX with separate file upload and URL download - Added remove image functionality with immediate save 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ model Recipe {
|
||||
sourceUrl String? // For imported recipes
|
||||
author String?
|
||||
cuisine String?
|
||||
category String?
|
||||
categories String[] @default([]) // Changed from single category to array
|
||||
rating Float?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -30,10 +30,10 @@ model Recipe {
|
||||
instructions Instruction[]
|
||||
images RecipeImage[]
|
||||
tags RecipeTag[]
|
||||
cookbooks CookbookRecipe[]
|
||||
|
||||
@@index([title])
|
||||
@@index([cuisine])
|
||||
@@index([category])
|
||||
}
|
||||
|
||||
model RecipeSection {
|
||||
@@ -127,3 +127,32 @@ model RecipeTag {
|
||||
@@index([recipeId])
|
||||
@@index([tagId])
|
||||
}
|
||||
|
||||
model Cookbook {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
coverImageUrl String?
|
||||
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
|
||||
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
recipes CookbookRecipe[]
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model CookbookRecipe {
|
||||
id String @id @default(cuid())
|
||||
cookbookId String
|
||||
recipeId String
|
||||
addedAt DateTime @default(now())
|
||||
|
||||
cookbook Cookbook @relation(fields: [cookbookId], references: [id], onDelete: Cascade)
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([cookbookId, recipeId])
|
||||
@@index([cookbookId])
|
||||
@@index([recipeId])
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import recipesRoutes from './routes/recipes.routes';
|
||||
import cookbooksRoutes from './routes/cookbooks.routes';
|
||||
import tagsRoutes from './routes/tags.routes';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -22,6 +24,8 @@ app.use('/uploads', express.static(uploadsPath));
|
||||
|
||||
// Routes
|
||||
app.use('/api/recipes', recipesRoutes);
|
||||
app.use('/api/cookbooks', cookbooksRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
287
packages/api/src/routes/cookbook-integration.test.ts
Normal file
287
packages/api/src/routes/cookbook-integration.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import cookbooksRouter from './cookbooks.routes';
|
||||
import tagsRouter from './tags.routes';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('../config/database', () => ({
|
||||
default: {
|
||||
cookbook: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
cookbookRecipe: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Cookbook & Tags - Integration Tests', () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/cookbooks', cookbooksRouter);
|
||||
app.use('/tags', tagsRouter);
|
||||
});
|
||||
|
||||
describe('Complete Cookbook Workflow', () => {
|
||||
it('should create a cookbook, add recipes, and retrieve it', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// Step 1: Create a cookbook
|
||||
const newCookbook = {
|
||||
name: 'Summer BBQ',
|
||||
description: 'Perfect recipes for outdoor grilling',
|
||||
};
|
||||
|
||||
const createdCookbook = {
|
||||
id: 'cb-summer',
|
||||
...newCookbook,
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date('2025-06-01'),
|
||||
updatedAt: new Date('2025-06-01'),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(createdCookbook as any);
|
||||
|
||||
const createResponse = await request(app).post('/cookbooks').send(newCookbook).expect(201);
|
||||
|
||||
expect(createResponse.body.data.id).toBe('cb-summer');
|
||||
expect(createResponse.body.data.name).toBe('Summer BBQ');
|
||||
|
||||
// Step 2: Add a recipe to the cookbook
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb-summer',
|
||||
recipeId: 'recipe-bbq-ribs',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const addRecipeResponse = await request(app)
|
||||
.post('/cookbooks/cb-summer/recipes/recipe-bbq-ribs')
|
||||
.expect(201);
|
||||
|
||||
expect(addRecipeResponse.body.data.cookbookId).toBe('cb-summer');
|
||||
expect(addRecipeResponse.body.data.recipeId).toBe('recipe-bbq-ribs');
|
||||
|
||||
// Step 3: Retrieve the cookbook with its recipes
|
||||
const cookbookWithRecipes = {
|
||||
...createdCookbook,
|
||||
recipes: [
|
||||
{
|
||||
recipe: {
|
||||
id: 'recipe-bbq-ribs',
|
||||
title: 'BBQ Ribs',
|
||||
description: 'Tender, fall-off-the-bone ribs',
|
||||
images: [],
|
||||
tags: [
|
||||
{ tag: { id: 't1', name: 'BBQ' } },
|
||||
{ tag: { id: 't2', name: 'Summer' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(cookbookWithRecipes as any);
|
||||
|
||||
const getResponse = await request(app).get('/cookbooks/cb-summer').expect(200);
|
||||
|
||||
expect(getResponse.body.data.name).toBe('Summer BBQ');
|
||||
expect(getResponse.body.data.recipes).toHaveLength(1);
|
||||
expect(getResponse.body.data.recipes[0].title).toBe('BBQ Ribs');
|
||||
expect(getResponse.body.data.recipes[0].tags).toEqual(['BBQ', 'Summer']);
|
||||
});
|
||||
|
||||
it('should prevent adding the same recipe twice to a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// First addition succeeds
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await request(app).post('/cookbooks/cb1/recipes/r1').expect(201);
|
||||
|
||||
// Second addition fails
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValueOnce({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await request(app).post('/cookbooks/cb1/recipes/r1').expect(400);
|
||||
});
|
||||
|
||||
it('should remove a recipe from a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
vi.mocked(prisma.default.cookbookRecipe.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/cookbooks/cb1/recipes/r1').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Recipe removed from cookbook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete Tags Workflow', () => {
|
||||
it('should create tags and handle case-insensitive duplicates', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// Create first tag
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.default.tag.create).mockResolvedValue({
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
} as any);
|
||||
|
||||
const firstResponse = await request(app).post('/tags').send({ name: 'Italian' }).expect(200);
|
||||
|
||||
expect(firstResponse.body.data.name).toBe('Italian');
|
||||
|
||||
// Try to create duplicate with different case - should return existing
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
} as any);
|
||||
|
||||
const duplicateResponse = await request(app).post('/tags').send({ name: 'italian' }).expect(200);
|
||||
|
||||
expect(duplicateResponse.body.data.id).toBe('t1');
|
||||
expect(duplicateResponse.body.data.name).toBe('Italian'); // Original casing preserved
|
||||
});
|
||||
|
||||
it('should list all tags sorted alphabetically', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'BBQ', _count: { recipes: 5 } },
|
||||
{ id: 't2', name: 'Dessert', _count: { recipes: 10 } },
|
||||
{ id: 't3', name: 'Italian', _count: { recipes: 15 } },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.default.tag.findMany).mockResolvedValue(mockTags as any);
|
||||
|
||||
const response = await request(app).get('/tags').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(3);
|
||||
expect(response.body.data[0].name).toBe('BBQ');
|
||||
expect(response.body.data[1].name).toBe('Dessert');
|
||||
expect(response.body.data[2].name).toBe('Italian');
|
||||
});
|
||||
|
||||
it('should only allow deletion of unused tags', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// Try to delete used tag
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
_count: { recipes: 5 },
|
||||
} as any);
|
||||
|
||||
const usedTagResponse = await request(app).delete('/tags/t1').expect(400);
|
||||
|
||||
expect(usedTagResponse.body.error).toContain('used by 5 recipe(s)');
|
||||
|
||||
// Delete unused tag
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValueOnce({
|
||||
id: 't2',
|
||||
name: 'Unused',
|
||||
_count: { recipes: 0 },
|
||||
} as any);
|
||||
vi.mocked(prisma.default.tag.delete).mockResolvedValue({} as any);
|
||||
|
||||
const unusedTagResponse = await request(app).delete('/tags/t2').expect(200);
|
||||
|
||||
expect(unusedTagResponse.body.message).toBe('Tag deleted successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cookbook and Tags Combined Workflow', () => {
|
||||
it('should create cookbook, create tags, and retrieve cookbook with tagged recipes', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
|
||||
// Create tags
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.default.tag.create).mockResolvedValueOnce({
|
||||
id: 't1',
|
||||
name: 'Quick',
|
||||
} as any);
|
||||
|
||||
await request(app).post('/tags').send({ name: 'Quick' }).expect(200);
|
||||
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.default.tag.create).mockResolvedValueOnce({
|
||||
id: 't2',
|
||||
name: 'Healthy',
|
||||
} as any);
|
||||
|
||||
await request(app).post('/tags').send({ name: 'Healthy' }).expect(200);
|
||||
|
||||
// Create cookbook
|
||||
vi.mocked(prisma.default.cookbook.create).mockResolvedValue({
|
||||
id: 'cb1',
|
||||
name: 'Weeknight Dinners',
|
||||
description: 'Quick and healthy meals',
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
await request(app)
|
||||
.post('/cookbooks')
|
||||
.send({ name: 'Weeknight Dinners', description: 'Quick and healthy meals' })
|
||||
.expect(201);
|
||||
|
||||
// Retrieve cookbook with tagged recipes
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue({
|
||||
id: 'cb1',
|
||||
name: 'Weeknight Dinners',
|
||||
description: 'Quick and healthy meals',
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
recipes: [
|
||||
{
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
title: 'Stir Fry',
|
||||
tags: [
|
||||
{ tag: { id: 't1', name: 'Quick' } },
|
||||
{ tag: { id: 't2', name: 'Healthy' } },
|
||||
],
|
||||
images: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks/cb1').expect(200);
|
||||
|
||||
expect(response.body.data.recipes[0].tags).toEqual(['Quick', 'Healthy']);
|
||||
});
|
||||
});
|
||||
});
|
||||
270
packages/api/src/routes/cookbooks.routes.test.ts
Normal file
270
packages/api/src/routes/cookbooks.routes.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import cookbooksRouter from './cookbooks.routes';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('../config/database', () => ({
|
||||
default: {
|
||||
cookbook: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
cookbookRecipe: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Cookbooks Routes - Unit Tests', () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/cookbooks', cookbooksRouter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /cookbooks', () => {
|
||||
it('should return all cookbooks with recipe counts', async () => {
|
||||
const mockCookbooks = [
|
||||
{
|
||||
id: 'cb1',
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
_count: { recipes: 5 },
|
||||
},
|
||||
{
|
||||
id: 'cb2',
|
||||
name: 'Holiday Recipes',
|
||||
description: 'Recipes for holidays',
|
||||
coverImageUrl: '/uploads/holiday.jpg',
|
||||
createdAt: new Date('2025-01-02'),
|
||||
updatedAt: new Date('2025-01-02'),
|
||||
_count: { recipes: 3 },
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockResolvedValue(mockCookbooks as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
expect(response.body.data[0]).toEqual({
|
||||
id: 'cb1',
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
recipeCount: 5,
|
||||
createdAt: mockCookbooks[0].createdAt.toISOString(),
|
||||
updatedAt: mockCookbooks[0].updatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.default.cookbook.findMany).toHaveBeenCalledWith({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true },
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findMany).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/cookbooks').expect(500);
|
||||
|
||||
expect(response.body.error).toBe('Failed to fetch cookbooks');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /cookbooks/:id', () => {
|
||||
it('should return a cookbook with its recipes', async () => {
|
||||
const mockCookbook = {
|
||||
id: 'cb1',
|
||||
name: 'Family Favorites',
|
||||
description: 'Our favorite family recipes',
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date('2025-01-01'),
|
||||
updatedAt: new Date('2025-01-01'),
|
||||
recipes: [
|
||||
{
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
title: 'Pasta Carbonara',
|
||||
description: 'Classic Italian pasta',
|
||||
images: [],
|
||||
tags: [
|
||||
{ tag: { id: 't1', name: 'Italian' } },
|
||||
{ tag: { id: 't2', name: 'Pasta' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(mockCookbook as any);
|
||||
|
||||
const response = await request(app).get('/cookbooks/cb1').expect(200);
|
||||
|
||||
expect(response.body.data.id).toBe('cb1');
|
||||
expect(response.body.data.recipes).toHaveLength(1);
|
||||
expect(response.body.data.recipes[0].tags).toEqual(['Italian', 'Pasta']);
|
||||
});
|
||||
|
||||
it('should return 404 if cookbook not found', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.findUnique).mockResolvedValue(null);
|
||||
|
||||
const response = await request(app).get('/cookbooks/nonexistent').expect(404);
|
||||
|
||||
expect(response.body.error).toBe('Cookbook not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /cookbooks', () => {
|
||||
it('should create a new cookbook', async () => {
|
||||
const newCookbook = {
|
||||
name: 'Quick Meals',
|
||||
description: 'Fast recipes for busy weeknights',
|
||||
coverImageUrl: '/uploads/quick-meals.jpg',
|
||||
};
|
||||
|
||||
const createdCookbook = {
|
||||
id: 'cb-new',
|
||||
...newCookbook,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.create).mockResolvedValue(createdCookbook as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks').send(newCookbook).expect(201);
|
||||
|
||||
expect(response.body.data.id).toBe('cb-new');
|
||||
expect(response.body.data.name).toBe('Quick Meals');
|
||||
expect(prisma.default.cookbook.create).toHaveBeenCalledWith({
|
||||
data: newCookbook,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if name is missing', async () => {
|
||||
const response = await request(app)
|
||||
.post('/cookbooks')
|
||||
.send({ description: 'Missing name' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Name is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /cookbooks/:id', () => {
|
||||
it('should update a cookbook', async () => {
|
||||
const updates = {
|
||||
name: 'Updated Name',
|
||||
description: 'Updated description',
|
||||
};
|
||||
|
||||
const updatedCookbook = {
|
||||
id: 'cb1',
|
||||
...updates,
|
||||
coverImageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.update).mockResolvedValue(updatedCookbook as any);
|
||||
|
||||
const response = await request(app).put('/cookbooks/cb1').send(updates).expect(200);
|
||||
|
||||
expect(response.body.data.name).toBe('Updated Name');
|
||||
expect(prisma.default.cookbook.update).toHaveBeenCalledWith({
|
||||
where: { id: 'cb1' },
|
||||
data: updates,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /cookbooks/:id', () => {
|
||||
it('should delete a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbook.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/cookbooks/cb1').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Cookbook deleted successfully');
|
||||
expect(prisma.default.cookbook.delete).toHaveBeenCalledWith({
|
||||
where: { id: 'cb1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /cookbooks/:id/recipes/:recipeId', () => {
|
||||
it('should add a recipe to a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.default.cookbookRecipe.create).mockResolvedValue({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks/cb1/recipes/r1').expect(201);
|
||||
|
||||
expect(response.body.data.cookbookId).toBe('cb1');
|
||||
expect(response.body.data.recipeId).toBe('r1');
|
||||
});
|
||||
|
||||
it('should return 400 if recipe is already in cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookRecipe.findUnique).mockResolvedValue({
|
||||
id: 'cbr1',
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
addedAt: new Date(),
|
||||
} as any);
|
||||
|
||||
const response = await request(app).post('/cookbooks/cb1/recipes/r1').expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Recipe already in cookbook');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /cookbooks/:id/recipes/:recipeId', () => {
|
||||
it('should remove a recipe from a cookbook', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.cookbookRecipe.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/cookbooks/cb1/recipes/r1').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Recipe removed from cookbook');
|
||||
expect(prisma.default.cookbookRecipe.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
cookbookId_recipeId: {
|
||||
cookbookId: 'cb1',
|
||||
recipeId: 'r1',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
370
packages/api/src/routes/cookbooks.routes.ts
Normal file
370
packages/api/src/routes/cookbooks.routes.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import prisma from '../config/database';
|
||||
import { StorageService } from '../services/storage.service';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024, // 20MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.originalname.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
|
||||
return cb(new Error('Only image files are allowed!'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
const storageService = StorageService.getInstance();
|
||||
|
||||
// Helper function to apply cookbook filters to existing recipes
|
||||
async function applyFiltersToExistingRecipes(cookbookId: string) {
|
||||
try {
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id: cookbookId }
|
||||
});
|
||||
|
||||
if (!cookbook) return;
|
||||
|
||||
// If no filters are set, nothing to do
|
||||
if (cookbook.autoFilterCategories.length === 0 && cookbook.autoFilterTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build query to find matching recipes
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
if (cookbook.autoFilterCategories.length > 0) {
|
||||
whereConditions.push({
|
||||
categories: {
|
||||
hasSome: cookbook.autoFilterCategories
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cookbook.autoFilterTags.length > 0) {
|
||||
whereConditions.push({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
name: { in: cookbook.autoFilterTags }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find matching recipes
|
||||
const matchingRecipes = await prisma.recipe.findMany({
|
||||
where: {
|
||||
OR: whereConditions
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
// Add each matching recipe to the cookbook
|
||||
for (const recipe of matchingRecipes) {
|
||||
try {
|
||||
await prisma.cookbookRecipe.create({
|
||||
data: {
|
||||
cookbookId: cookbookId,
|
||||
recipeId: recipe.id
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Ignore unique constraint violations (recipe already in cookbook)
|
||||
if (error.code !== 'P2002') {
|
||||
console.error(`Error adding recipe ${recipe.id} to cookbook:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Applied filters to cookbook ${cookbook.name}: added ${matchingRecipes.length} recipes`);
|
||||
} catch (error) {
|
||||
console.error('Error in applyFiltersToExistingRecipes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all cookbooks with recipe count
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const cookbooks = await prisma.cookbook.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true }
|
||||
}
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
|
||||
const response = cookbooks.map(cookbook => ({
|
||||
id: cookbook.id,
|
||||
name: cookbook.name,
|
||||
description: cookbook.description,
|
||||
coverImageUrl: cookbook.coverImageUrl,
|
||||
autoFilterCategories: cookbook.autoFilterCategories,
|
||||
autoFilterTags: cookbook.autoFilterTags,
|
||||
recipeCount: cookbook._count.recipes,
|
||||
createdAt: cookbook.createdAt,
|
||||
updatedAt: cookbook.updatedAt
|
||||
}));
|
||||
|
||||
res.json({ data: response });
|
||||
} catch (error) {
|
||||
console.error('Error fetching cookbooks:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch cookbooks' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a single cookbook with all its recipes
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
recipes: {
|
||||
include: {
|
||||
recipe: {
|
||||
include: {
|
||||
images: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { addedAt: 'desc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!cookbook) {
|
||||
return res.status(404).json({ error: 'Cookbook not found' });
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: cookbook.id,
|
||||
name: cookbook.name,
|
||||
description: cookbook.description,
|
||||
coverImageUrl: cookbook.coverImageUrl,
|
||||
autoFilterCategories: cookbook.autoFilterCategories,
|
||||
autoFilterTags: cookbook.autoFilterTags,
|
||||
createdAt: cookbook.createdAt,
|
||||
updatedAt: cookbook.updatedAt,
|
||||
recipes: cookbook.recipes.map(cr => ({
|
||||
...cr.recipe,
|
||||
tags: cr.recipe.tags.map(rt => rt.tag.name)
|
||||
}))
|
||||
};
|
||||
|
||||
res.json({ data: response });
|
||||
} catch (error) {
|
||||
console.error('Error fetching cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new cookbook
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const cookbook = await prisma.cookbook.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
coverImageUrl,
|
||||
autoFilterCategories: autoFilterCategories || [],
|
||||
autoFilterTags: autoFilterTags || []
|
||||
}
|
||||
});
|
||||
|
||||
// Apply filters to existing recipes
|
||||
await applyFiltersToExistingRecipes(cookbook.id);
|
||||
|
||||
res.status(201).json({ data: cookbook });
|
||||
} catch (error) {
|
||||
console.error('Error creating cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to create cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a cookbook
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, coverImageUrl, autoFilterCategories, autoFilterTags } = req.body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (coverImageUrl !== undefined) updateData.coverImageUrl = coverImageUrl;
|
||||
if (autoFilterCategories !== undefined) updateData.autoFilterCategories = autoFilterCategories;
|
||||
if (autoFilterTags !== undefined) updateData.autoFilterTags = autoFilterTags;
|
||||
|
||||
const cookbook = await prisma.cookbook.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// Apply filters to existing recipes if filters were updated
|
||||
if (autoFilterCategories !== undefined || autoFilterTags !== undefined) {
|
||||
await applyFiltersToExistingRecipes(id);
|
||||
}
|
||||
|
||||
res.json({ data: cookbook });
|
||||
} catch (error) {
|
||||
console.error('Error updating cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to update cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a cookbook
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await prisma.cookbook.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Cookbook deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to delete cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a recipe to a cookbook
|
||||
router.post('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, recipeId } = req.params;
|
||||
|
||||
// Check if recipe is already in cookbook
|
||||
const existing = await prisma.cookbookRecipe.findUnique({
|
||||
where: {
|
||||
cookbookId_recipeId: {
|
||||
cookbookId: id,
|
||||
recipeId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Recipe already in cookbook' });
|
||||
}
|
||||
|
||||
const cookbookRecipe = await prisma.cookbookRecipe.create({
|
||||
data: {
|
||||
cookbookId: id,
|
||||
recipeId
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({ data: cookbookRecipe });
|
||||
} catch (error) {
|
||||
console.error('Error adding recipe to cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to add recipe to cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove a recipe from a cookbook
|
||||
router.delete('/:id/recipes/:recipeId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id, recipeId } = req.params;
|
||||
|
||||
await prisma.cookbookRecipe.delete({
|
||||
where: {
|
||||
cookbookId_recipeId: {
|
||||
cookbookId: id,
|
||||
recipeId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ message: 'Recipe removed from cookbook' });
|
||||
} catch (error) {
|
||||
console.error('Error removing recipe from cookbook:', error);
|
||||
res.status(500).json({ error: 'Failed to remove recipe from cookbook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload cookbook cover image
|
||||
router.post('/:id/image', upload.single('image'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No image provided' });
|
||||
}
|
||||
|
||||
// Delete old cover image if it exists
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (cookbook?.coverImageUrl) {
|
||||
await storageService.deleteFile(cookbook.coverImageUrl);
|
||||
}
|
||||
|
||||
// Save new image
|
||||
const imageUrl = await storageService.saveFile(req.file, 'cookbooks');
|
||||
|
||||
// Update cookbook with new image URL
|
||||
const updated = await prisma.cookbook.update({
|
||||
where: { id },
|
||||
data: { coverImageUrl: imageUrl }
|
||||
});
|
||||
|
||||
res.json({ data: { url: imageUrl }, message: 'Image uploaded successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error uploading cookbook image:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
}
|
||||
});
|
||||
|
||||
// Download and save image from URL
|
||||
router.post('/:id/image-from-url', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { url } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
// Delete old cover image if it exists
|
||||
const cookbook = await prisma.cookbook.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (cookbook?.coverImageUrl) {
|
||||
await storageService.deleteFile(cookbook.coverImageUrl);
|
||||
}
|
||||
|
||||
// Download and save image from URL
|
||||
const imageUrl = await storageService.downloadAndSaveImage(url, 'cookbooks');
|
||||
|
||||
// Update cookbook with new image URL
|
||||
await prisma.cookbook.update({
|
||||
where: { id },
|
||||
data: { coverImageUrl: imageUrl }
|
||||
});
|
||||
|
||||
res.json({ data: { url: imageUrl }, message: 'Image downloaded and saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error downloading cookbook image:', error);
|
||||
res.status(500).json({ error: 'Failed to download image from URL' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -23,6 +23,82 @@ const upload = multer({
|
||||
const storageService = StorageService.getInstance();
|
||||
const scraperService = new ScraperService();
|
||||
|
||||
// Helper function to auto-add recipe to cookbooks based on their filters
|
||||
async function autoAddToCookbooks(recipeId: string) {
|
||||
try {
|
||||
// Get the recipe with its category and tags
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: recipeId },
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!recipe) return;
|
||||
|
||||
const recipeTags = recipe.tags.map(rt => rt.tag.name);
|
||||
const recipeCategories = recipe.categories || [];
|
||||
|
||||
// Get all cookbooks with auto-filters
|
||||
const cookbooks = await prisma.cookbook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ autoFilterCategories: { isEmpty: false } },
|
||||
{ autoFilterTags: { isEmpty: false } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Check each cookbook to see if recipe matches
|
||||
for (const cookbook of cookbooks) {
|
||||
let shouldAdd = false;
|
||||
|
||||
// Check if any recipe category matches any of the cookbook's filter categories
|
||||
if (cookbook.autoFilterCategories.length > 0 && recipeCategories.length > 0) {
|
||||
const hasMatchingCategory = recipeCategories.some(cat =>
|
||||
cookbook.autoFilterCategories.includes(cat)
|
||||
);
|
||||
if (hasMatchingCategory) {
|
||||
shouldAdd = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if recipe has any of the cookbook's filter tags
|
||||
if (cookbook.autoFilterTags.length > 0 && recipeTags.length > 0) {
|
||||
const hasMatchingTag = cookbook.autoFilterTags.some(filterTag =>
|
||||
recipeTags.includes(filterTag)
|
||||
);
|
||||
if (hasMatchingTag) {
|
||||
shouldAdd = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add recipe to cookbook if it matches and isn't already added
|
||||
if (shouldAdd) {
|
||||
try {
|
||||
await prisma.cookbookRecipe.create({
|
||||
data: {
|
||||
cookbookId: cookbook.id,
|
||||
recipeId: recipeId
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
// Ignore unique constraint violations (recipe already in cookbook)
|
||||
if (error.code !== 'P2002') {
|
||||
console.error(`Error auto-adding recipe to cookbook ${cookbook.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in autoAddToCookbooks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all recipes
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
@@ -39,7 +115,11 @@ router.get('/', async (req, res) => {
|
||||
];
|
||||
}
|
||||
if (cuisine) where.cuisine = cuisine;
|
||||
if (category) where.category = category;
|
||||
if (category) {
|
||||
where.categories = {
|
||||
has: category as string
|
||||
};
|
||||
}
|
||||
|
||||
const [recipes, total] = await Promise.all([
|
||||
prisma.recipe.findMany({
|
||||
@@ -250,6 +330,9 @@ router.post('/', async (req, res) => {
|
||||
// Automatically generate ingredient-instruction mappings
|
||||
await autoMapIngredients(recipe.id);
|
||||
|
||||
// Auto-add to cookbooks based on filters
|
||||
await autoAddToCookbooks(recipe.id);
|
||||
|
||||
res.status(201).json({ data: recipe });
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
@@ -343,6 +426,9 @@ router.put('/:id', async (req, res) => {
|
||||
// Regenerate ingredient-instruction mappings
|
||||
await autoMapIngredients(req.params.id);
|
||||
|
||||
// Auto-add to cookbooks based on filters
|
||||
await autoAddToCookbooks(req.params.id);
|
||||
|
||||
res.json({ data: recipe });
|
||||
} catch (error) {
|
||||
console.error('Error updating recipe:', error);
|
||||
|
||||
182
packages/api/src/routes/tags.routes.test.ts
Normal file
182
packages/api/src/routes/tags.routes.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import tagsRouter from './tags.routes';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('../config/database', () => ({
|
||||
default: {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Tags Routes - Unit Tests', () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/tags', tagsRouter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /tags', () => {
|
||||
it('should return all tags with recipe counts', async () => {
|
||||
const mockTags = [
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
_count: { recipes: 12 },
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
name: 'Quick',
|
||||
_count: { recipes: 8 },
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
name: 'Vegetarian',
|
||||
_count: { recipes: 15 },
|
||||
},
|
||||
];
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findMany).mockResolvedValue(mockTags as any);
|
||||
|
||||
const response = await request(app).get('/tags').expect(200);
|
||||
|
||||
expect(response.body.data).toHaveLength(3);
|
||||
expect(response.body.data[0]).toEqual({
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
recipeCount: 12,
|
||||
});
|
||||
expect(prisma.default.tag.findMany).toHaveBeenCalledWith({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findMany).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const response = await request(app).get('/tags').expect(500);
|
||||
|
||||
expect(response.body.error).toBe('Failed to fetch tags');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tags', () => {
|
||||
it('should create a new tag if it does not exist', async () => {
|
||||
const newTag = { name: 'Dessert' };
|
||||
const createdTag = {
|
||||
id: 't-new',
|
||||
name: 'Dessert',
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.default.tag.create).mockResolvedValue(createdTag as any);
|
||||
|
||||
const response = await request(app).post('/tags').send(newTag).expect(200);
|
||||
|
||||
expect(response.body.data.id).toBe('t-new');
|
||||
expect(response.body.data.name).toBe('Dessert');
|
||||
expect(prisma.default.tag.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
name: {
|
||||
equals: 'Dessert',
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(prisma.default.tag.create).toHaveBeenCalledWith({
|
||||
data: { name: 'Dessert' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return existing tag if it already exists (case-insensitive)', async () => {
|
||||
const newTag = { name: 'dessert' };
|
||||
const existingTag = {
|
||||
id: 't-existing',
|
||||
name: 'Dessert',
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findFirst).mockResolvedValue(existingTag as any);
|
||||
|
||||
const response = await request(app).post('/tags').send(newTag).expect(200);
|
||||
|
||||
expect(response.body.data.id).toBe('t-existing');
|
||||
expect(response.body.data.name).toBe('Dessert');
|
||||
expect(prisma.default.tag.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 if tag name is missing', async () => {
|
||||
const response = await request(app).post('/tags').send({}).expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Tag name is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /tags/:id', () => {
|
||||
it('should delete a tag that is not used by any recipes', async () => {
|
||||
const mockTag = {
|
||||
id: 't1',
|
||||
name: 'Unused',
|
||||
_count: { recipes: 0 },
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(mockTag as any);
|
||||
vi.mocked(prisma.default.tag.delete).mockResolvedValue({} as any);
|
||||
|
||||
const response = await request(app).delete('/tags/t1').expect(200);
|
||||
|
||||
expect(response.body.message).toBe('Tag deleted successfully');
|
||||
expect(prisma.default.tag.delete).toHaveBeenCalledWith({
|
||||
where: { id: 't1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 if tag does not exist', async () => {
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(null);
|
||||
|
||||
const response = await request(app).delete('/tags/nonexistent').expect(404);
|
||||
|
||||
expect(response.body.error).toBe('Tag not found');
|
||||
});
|
||||
|
||||
it('should return 400 if tag is used by recipes', async () => {
|
||||
const mockTag = {
|
||||
id: 't1',
|
||||
name: 'Italian',
|
||||
_count: { recipes: 5 },
|
||||
};
|
||||
|
||||
const prisma = await import('../config/database');
|
||||
vi.mocked(prisma.default.tag.findUnique).mockResolvedValue(mockTag as any);
|
||||
|
||||
const response = await request(app).delete('/tags/t1').expect(400);
|
||||
|
||||
expect(response.body.error).toBe('Cannot delete tag "Italian" as it is used by 5 recipe(s)');
|
||||
expect(prisma.default.tag.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
100
packages/api/src/routes/tags.routes.ts
Normal file
100
packages/api/src/routes/tags.routes.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import prisma from '../config/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get all tags
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const tags = await prisma.tag.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true }
|
||||
}
|
||||
},
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
const response = tags.map(tag => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
recipeCount: tag._count.recipes
|
||||
}));
|
||||
|
||||
res.json({ data: response });
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch tags' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get or create a tag by name
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Tag name is required' });
|
||||
}
|
||||
|
||||
// Try to find existing tag (case-insensitive)
|
||||
let tag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
equals: name,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If not found, create it
|
||||
if (!tag) {
|
||||
tag = await prisma.tag.create({
|
||||
data: { name }
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ data: tag });
|
||||
} catch (error) {
|
||||
console.error('Error creating/finding tag:', error);
|
||||
res.status(500).json({ error: 'Failed to create/find tag' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a tag (only if not used by any recipe)
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if tag is used by any recipes
|
||||
const tag = await prisma.tag.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { recipes: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
|
||||
if (tag._count.recipes > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot delete tag "${tag.name}" as it is used by ${tag._count.recipes} recipe(s)`
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.tag.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Tag deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting tag:', error);
|
||||
res.status(500).json({ error: 'Failed to delete tag' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import axios from 'axios';
|
||||
import { storageConfig } from '../config/storage';
|
||||
|
||||
export class StorageService {
|
||||
@@ -63,4 +64,55 @@ export class StorageService {
|
||||
throw new Error('S3 storage not yet implemented');
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAndSaveImage(url: string, folder: string = 'images'): Promise<string> {
|
||||
try {
|
||||
// Download the image
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 30000,
|
||||
maxContentLength: 20 * 1024 * 1024, // 20MB limit
|
||||
});
|
||||
|
||||
// Get the file extension from content-type or URL
|
||||
let extension = 'jpg';
|
||||
const contentType = response.headers['content-type'];
|
||||
if (contentType) {
|
||||
const match = contentType.match(/image\/(jpeg|jpg|png|gif|webp)/);
|
||||
if (match) {
|
||||
extension = match[1] === 'jpeg' ? 'jpg' : match[1];
|
||||
}
|
||||
} else {
|
||||
// Try to get extension from URL
|
||||
const urlMatch = url.match(/\.(jpg|jpeg|png|gif|webp)(\?|$)/i);
|
||||
if (urlMatch) {
|
||||
extension = urlMatch[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Create a file object similar to multer's format
|
||||
const buffer = Buffer.from(response.data);
|
||||
const filename = `${Date.now()}.${extension}`;
|
||||
|
||||
// Save using local storage
|
||||
if (storageConfig.type === 'local') {
|
||||
const basePath = storageConfig.localPath || './uploads';
|
||||
const folderPath = path.join(basePath, folder);
|
||||
await fs.mkdir(folderPath, { recursive: true });
|
||||
|
||||
const filePath = path.join(folderPath, filename);
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
return `/uploads/${folder}/${filename}`;
|
||||
} else if (storageConfig.type === 's3') {
|
||||
// TODO: Implement S3 upload
|
||||
throw new Error('S3 storage not yet implemented');
|
||||
}
|
||||
|
||||
throw new Error('Invalid storage type');
|
||||
} catch (error) {
|
||||
console.error('Error downloading image from URL:', error);
|
||||
throw new Error('Failed to download image from URL');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('Shared Types', () => {
|
||||
totalTime: 45,
|
||||
servings: 4,
|
||||
cuisine: 'Italian',
|
||||
category: 'Main Course',
|
||||
categories: ['Main Course', 'Pasta'],
|
||||
rating: 4.5,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface Recipe {
|
||||
sourceUrl?: string; // For imported recipes
|
||||
author?: string;
|
||||
cuisine?: string;
|
||||
category?: string;
|
||||
categories?: string[]; // Changed from single category to array
|
||||
tags?: string[];
|
||||
rating?: number;
|
||||
createdAt: Date;
|
||||
@@ -92,3 +92,24 @@ export interface PaginatedResponse<T> {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Cookbook {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
coverImageUrl?: string;
|
||||
autoFilterCategories?: string[]; // Auto-add recipes matching these categories
|
||||
autoFilterTags?: string[]; // Auto-add recipes matching these tags
|
||||
recipeCount?: number; // Computed field for display
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CookbookWithRecipes extends Cookbook {
|
||||
recipes: Recipe[];
|
||||
}
|
||||
|
||||
@@ -616,3 +616,65 @@ button:disabled {
|
||||
font-size: 0.9rem;
|
||||
border-left: 4px solid #1976d2;
|
||||
}
|
||||
|
||||
/* Cookbook Modal Styles */
|
||||
.cookbook-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.cookbook-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cookbook-item:hover {
|
||||
background-color: #e8f5e9;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.cookbook-item h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.cookbook-item p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cookbook-item .recipe-count {
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background-color: #1b5e20;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import Cookbooks from './pages/Cookbooks';
|
||||
import CookbookDetail from './pages/CookbookDetail';
|
||||
import EditCookbook from './pages/EditCookbook';
|
||||
import RecipeList from './pages/RecipeList';
|
||||
import RecipeDetail from './pages/RecipeDetail';
|
||||
import RecipeImport from './pages/RecipeImport';
|
||||
@@ -15,9 +18,10 @@ function App() {
|
||||
<div className="container">
|
||||
<h1 className="logo"><Link to="/">🌿 Basil</Link></h1>
|
||||
<nav>
|
||||
<Link to="/">Recipes</Link>
|
||||
<Link to="/new">New Recipe</Link>
|
||||
<Link to="/import">Import Recipe</Link>
|
||||
<Link to="/">Cookbooks</Link>
|
||||
<Link to="/recipes">All Recipes</Link>
|
||||
<Link to="/recipes/new">New Recipe</Link>
|
||||
<Link to="/recipes/import">Import Recipe</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -25,12 +29,15 @@ function App() {
|
||||
<main className="main">
|
||||
<div className="container">
|
||||
<Routes>
|
||||
<Route path="/" element={<RecipeList />} />
|
||||
<Route path="/" element={<Cookbooks />} />
|
||||
<Route path="/cookbooks/:id" element={<CookbookDetail />} />
|
||||
<Route path="/cookbooks/:id/edit" element={<EditCookbook />} />
|
||||
<Route path="/recipes" element={<RecipeList />} />
|
||||
<Route path="/recipes/:id" element={<RecipeDetail />} />
|
||||
<Route path="/recipes/:id/edit" element={<UnifiedEditRecipe />} />
|
||||
<Route path="/recipes/:id/cook" element={<CookingMode />} />
|
||||
<Route path="/new" element={<NewRecipe />} />
|
||||
<Route path="/import" element={<RecipeImport />} />
|
||||
<Route path="/recipes/new" element={<NewRecipe />} />
|
||||
<Route path="/recipes/import" element={<RecipeImport />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
323
packages/web/src/pages/CookbookDetail.tsx
Normal file
323
packages/web/src/pages/CookbookDetail.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { CookbookWithRecipes, Recipe } from '@basil/shared';
|
||||
import { cookbooksApi } from '../services/api';
|
||||
import '../styles/CookbookDetail.css';
|
||||
|
||||
function CookbookDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [cookbook, setCookbook] = useState<CookbookWithRecipes | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
||||
const [selectedCuisine, setSelectedCuisine] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadCookbook(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadCookbook = async (cookbookId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await cookbooksApi.getById(cookbookId);
|
||||
setCookbook(response.data || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load cookbook');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCookbook = async () => {
|
||||
if (!cookbook) return;
|
||||
|
||||
if (confirm(`Are you sure you want to delete "${cookbook.name}"? This will not delete the recipes.`)) {
|
||||
try {
|
||||
await cookbooksApi.delete(cookbook.id);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete cookbook:', err);
|
||||
alert('Failed to delete cookbook');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRecipe = async (recipeId: string) => {
|
||||
if (!cookbook) return;
|
||||
|
||||
if (confirm('Remove this recipe from the cookbook?')) {
|
||||
try {
|
||||
await cookbooksApi.removeRecipe(cookbook.id, recipeId);
|
||||
loadCookbook(cookbook.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove recipe:', err);
|
||||
alert('Failed to remove recipe');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
// Get all unique tags and categories from recipes
|
||||
const getAllTags = (): string[] => {
|
||||
if (!cookbook) return [];
|
||||
const tagSet = new Set<string>();
|
||||
cookbook.recipes.forEach(recipe => {
|
||||
recipe.tags?.forEach(tag => tagSet.add(tag));
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
};
|
||||
|
||||
const getAllCategories = (): string[] => {
|
||||
if (!cookbook) return [];
|
||||
const categorySet = new Set<string>();
|
||||
cookbook.recipes.forEach(recipe => {
|
||||
if (recipe.categories) {
|
||||
recipe.categories.forEach(cat => categorySet.add(cat));
|
||||
}
|
||||
});
|
||||
return Array.from(categorySet).sort();
|
||||
};
|
||||
|
||||
const getAllCuisines = (): string[] => {
|
||||
if (!cookbook) return [];
|
||||
const cuisineSet = new Set<string>();
|
||||
cookbook.recipes.forEach(recipe => {
|
||||
if (recipe.cuisine) cuisineSet.add(recipe.cuisine);
|
||||
});
|
||||
return Array.from(cuisineSet).sort();
|
||||
};
|
||||
|
||||
// Filter recipes based on selected filters
|
||||
const getFilteredRecipes = (): Recipe[] => {
|
||||
if (!cookbook) return [];
|
||||
|
||||
return cookbook.recipes.filter(recipe => {
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesTitle = recipe.title.toLowerCase().includes(query);
|
||||
const matchesDescription = recipe.description?.toLowerCase().includes(query);
|
||||
if (!matchesTitle && !matchesDescription) return false;
|
||||
}
|
||||
|
||||
// Tags filter (recipe must have ALL selected tags)
|
||||
if (selectedTags.length > 0) {
|
||||
const recipeTags = recipe.tags || [];
|
||||
const hasAllTags = selectedTags.every(tag => recipeTags.includes(tag));
|
||||
if (!hasAllTags) return false;
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (selectedCategory) {
|
||||
const recipeCategories = recipe.categories || [];
|
||||
if (!recipeCategories.includes(selectedCategory)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cuisine filter
|
||||
if (selectedCuisine && recipe.cuisine !== selectedCuisine) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedTags([]);
|
||||
setSelectedCategory('');
|
||||
setSelectedCuisine('');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="cookbook-detail-page">
|
||||
<div className="loading">Loading cookbook...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !cookbook) {
|
||||
return (
|
||||
<div className="cookbook-detail-page">
|
||||
<div className="error">{error || 'Cookbook not found'}</div>
|
||||
<button onClick={() => navigate('/')}>← Back to Cookbooks</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredRecipes = getFilteredRecipes();
|
||||
const allTags = getAllTags();
|
||||
const allCategories = getAllCategories();
|
||||
const allCuisines = getAllCuisines();
|
||||
const hasActiveFilters = searchQuery || selectedTags.length > 0 || selectedCategory || selectedCuisine;
|
||||
|
||||
return (
|
||||
<div className="cookbook-detail-page">
|
||||
<header className="cookbook-header">
|
||||
<button onClick={() => navigate('/')} className="back-btn">
|
||||
← Back to Cookbooks
|
||||
</button>
|
||||
<div className="cookbook-title-section">
|
||||
<h1>{cookbook.name}</h1>
|
||||
{cookbook.description && <p className="cookbook-description">{cookbook.description}</p>}
|
||||
<p className="recipe-count">{cookbook.recipes.length} recipes</p>
|
||||
</div>
|
||||
<div className="cookbook-actions">
|
||||
<button onClick={() => navigate(`/cookbooks/${cookbook.id}/edit`)} className="btn-edit">
|
||||
Edit Cookbook
|
||||
</button>
|
||||
<button onClick={handleDeleteCookbook} className="btn-delete">
|
||||
Delete Cookbook
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Quick Filter Tags */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="quick-filters-section">
|
||||
<h3>Quick Filters</h3>
|
||||
<div className="quick-tag-buttons">
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
className={`quick-tag-btn ${selectedTags.includes(tag) ? 'active' : ''}`}
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
{selectedTags.includes(tag) && <span className="checkmark">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="filters-section">
|
||||
<div className="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search recipes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
{allCategories.length > 0 && (
|
||||
<div className="filter-group">
|
||||
<label htmlFor="category-filter">Category:</label>
|
||||
<select
|
||||
id="category-filter"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option key={category} value={category}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allCuisines.length > 0 && (
|
||||
<div className="filter-group">
|
||||
<label htmlFor="cuisine-filter">Cuisine:</label>
|
||||
<select
|
||||
id="cuisine-filter"
|
||||
value={selectedCuisine}
|
||||
onChange={(e) => setSelectedCuisine(e.target.value)}
|
||||
>
|
||||
<option value="">All Cuisines</option>
|
||||
{allCuisines.map(cuisine => (
|
||||
<option key={cuisine} value={cuisine}>{cuisine}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="btn-clear-filters">
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="results-section">
|
||||
<p className="results-count">
|
||||
Showing {filteredRecipes.length} of {cookbook.recipes.length} recipes
|
||||
</p>
|
||||
|
||||
{filteredRecipes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
{cookbook.recipes.length === 0 ? (
|
||||
<p>No recipes in this cookbook yet. Add recipes from the recipe detail pages!</p>
|
||||
) : (
|
||||
<p>No recipes match your filters. Try adjusting your search criteria.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="recipes-grid">
|
||||
{filteredRecipes.map(recipe => (
|
||||
<div key={recipe.id} className="recipe-card">
|
||||
<div onClick={() => navigate(`/recipes/${recipe.id}`)}>
|
||||
{recipe.imageUrl ? (
|
||||
<img src={recipe.imageUrl} alt={recipe.title} className="recipe-image" />
|
||||
) : (
|
||||
<div className="recipe-image-placeholder">
|
||||
<span>🍳</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="recipe-info">
|
||||
<h3>{recipe.title}</h3>
|
||||
{recipe.description && (
|
||||
<p className="description">{recipe.description.substring(0, 100)}...</p>
|
||||
)}
|
||||
<div className="recipe-meta">
|
||||
{recipe.totalTime && <span>⏱️ {recipe.totalTime} min</span>}
|
||||
{recipe.rating && <span>⭐ {recipe.rating}</span>}
|
||||
</div>
|
||||
{recipe.tags && recipe.tags.length > 0 && (
|
||||
<div className="recipe-tags">
|
||||
{recipe.tags.map(tag => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveRecipe(recipe.id)}
|
||||
className="remove-recipe-btn"
|
||||
title="Remove from cookbook"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CookbookDetail;
|
||||
324
packages/web/src/pages/Cookbooks.tsx
Normal file
324
packages/web/src/pages/Cookbooks.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Cookbook, Recipe, Tag } from '@basil/shared';
|
||||
import { cookbooksApi, recipesApi, tagsApi } from '../services/api';
|
||||
import '../styles/Cookbooks.css';
|
||||
|
||||
function Cookbooks() {
|
||||
const navigate = useNavigate();
|
||||
const [cookbooks, setCookbooks] = useState<Cookbook[]>([]);
|
||||
const [recentRecipes, setRecentRecipes] = useState<Recipe[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newCookbookName, setNewCookbookName] = useState('');
|
||||
const [newCookbookDescription, setNewCookbookDescription] = useState('');
|
||||
const [autoFilterCategories, setAutoFilterCategories] = useState<string[]>([]);
|
||||
const [autoFilterTags, setAutoFilterTags] = useState<string[]>([]);
|
||||
const [categoryInput, setCategoryInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [cookbooksResponse, recipesResponse, tagsResponse] = await Promise.all([
|
||||
cookbooksApi.getAll(),
|
||||
recipesApi.getAll({ limit: 6 }), // Get 6 most recent recipes
|
||||
tagsApi.getAll()
|
||||
]);
|
||||
|
||||
setCookbooks(cookbooksResponse.data || []);
|
||||
setRecentRecipes(recipesResponse.data || []);
|
||||
setAvailableTags(tagsResponse.data || []);
|
||||
|
||||
// Extract unique categories from recent recipes
|
||||
const categories = new Set<string>();
|
||||
(recipesResponse.data || []).forEach(recipe => {
|
||||
if (recipe.categories) {
|
||||
recipe.categories.forEach(cat => categories.add(cat));
|
||||
}
|
||||
});
|
||||
setAvailableCategories(Array.from(categories).sort());
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load data');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCookbook = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newCookbookName.trim()) {
|
||||
alert('Please enter a cookbook name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cookbooksApi.create({
|
||||
name: newCookbookName,
|
||||
description: newCookbookDescription || undefined,
|
||||
autoFilterCategories: autoFilterCategories.length > 0 ? autoFilterCategories : undefined,
|
||||
autoFilterTags: autoFilterTags.length > 0 ? autoFilterTags : undefined
|
||||
});
|
||||
|
||||
setNewCookbookName('');
|
||||
setNewCookbookDescription('');
|
||||
setAutoFilterCategories([]);
|
||||
setAutoFilterTags([]);
|
||||
setCategoryInput('');
|
||||
setTagInput('');
|
||||
setShowCreateModal(false);
|
||||
loadData(); // Reload cookbooks
|
||||
} catch (err) {
|
||||
console.error('Failed to create cookbook:', err);
|
||||
alert('Failed to create cookbook');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCategory = () => {
|
||||
const trimmed = categoryInput.trim();
|
||||
if (trimmed && !autoFilterCategories.includes(trimmed)) {
|
||||
setAutoFilterCategories([...autoFilterCategories, trimmed]);
|
||||
setCategoryInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (category: string) => {
|
||||
setAutoFilterCategories(autoFilterCategories.filter(c => c !== category));
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
const trimmed = tagInput.trim();
|
||||
if (trimmed && !autoFilterTags.includes(trimmed)) {
|
||||
setAutoFilterTags([...autoFilterTags, trimmed]);
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setAutoFilterTags(autoFilterTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="cookbooks-page">
|
||||
<div className="loading">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="cookbooks-page">
|
||||
<div className="error">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cookbooks-page">
|
||||
<header className="cookbooks-header">
|
||||
<h1>My Cookbooks</h1>
|
||||
<div className="header-actions">
|
||||
<button onClick={() => navigate('/recipes')} className="btn-secondary">
|
||||
All Recipes
|
||||
</button>
|
||||
<button onClick={() => navigate('/recipes/import')} className="btn-secondary">
|
||||
Import Recipe
|
||||
</button>
|
||||
<button onClick={() => setShowCreateModal(true)} className="btn-primary">
|
||||
+ New Cookbook
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Cookbooks Grid */}
|
||||
<section className="cookbooks-section">
|
||||
<h2>Cookbooks</h2>
|
||||
{cookbooks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No cookbooks yet. Create your first cookbook to organize your recipes!</p>
|
||||
<button onClick={() => setShowCreateModal(true)} className="btn-primary">
|
||||
Create Cookbook
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cookbooks-grid">
|
||||
{cookbooks.map((cookbook) => (
|
||||
<div
|
||||
key={cookbook.id}
|
||||
className="cookbook-card"
|
||||
onClick={() => navigate(`/cookbooks/${cookbook.id}`)}
|
||||
>
|
||||
{cookbook.coverImageUrl ? (
|
||||
<img src={cookbook.coverImageUrl} alt={cookbook.name} className="cookbook-cover" />
|
||||
) : (
|
||||
<div className="cookbook-cover-placeholder">
|
||||
<span>📚</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cookbook-info">
|
||||
<h3>{cookbook.name}</h3>
|
||||
{cookbook.description && <p className="description">{cookbook.description}</p>}
|
||||
<p className="recipe-count">{cookbook.recipeCount || 0} recipes</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent Recipes */}
|
||||
<section className="recent-recipes-section">
|
||||
<div className="section-header">
|
||||
<h2>Recent Recipes</h2>
|
||||
<button onClick={() => navigate('/recipes')} className="btn-link">
|
||||
View all →
|
||||
</button>
|
||||
</div>
|
||||
{recentRecipes.length === 0 ? (
|
||||
<p className="empty-state">No recipes yet.</p>
|
||||
) : (
|
||||
<div className="recipes-grid">
|
||||
{recentRecipes.map((recipe) => (
|
||||
<div
|
||||
key={recipe.id}
|
||||
className="recipe-card"
|
||||
onClick={() => navigate(`/recipes/${recipe.id}`)}
|
||||
>
|
||||
{recipe.imageUrl ? (
|
||||
<img src={recipe.imageUrl} alt={recipe.title} className="recipe-image" />
|
||||
) : (
|
||||
<div className="recipe-image-placeholder">
|
||||
<span>🍳</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="recipe-info">
|
||||
<h3>{recipe.title}</h3>
|
||||
{recipe.description && (
|
||||
<p className="description">{recipe.description.substring(0, 100)}...</p>
|
||||
)}
|
||||
<div className="recipe-meta">
|
||||
{recipe.totalTime && <span>⏱️ {recipe.totalTime} min</span>}
|
||||
{recipe.rating && <span>⭐ {recipe.rating}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Create Cookbook Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Create New Cookbook</h2>
|
||||
<form onSubmit={handleCreateCookbook}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cookbook-name">Name *</label>
|
||||
<input
|
||||
id="cookbook-name"
|
||||
type="text"
|
||||
value={newCookbookName}
|
||||
onChange={(e) => setNewCookbookName(e.target.value)}
|
||||
placeholder="e.g., Family Favorites, Holiday Recipes"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cookbook-description">Description</label>
|
||||
<textarea
|
||||
id="cookbook-description"
|
||||
value={newCookbookDescription}
|
||||
onChange={(e) => setNewCookbookDescription(e.target.value)}
|
||||
placeholder="Describe this cookbook..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Auto-Add Categories (Optional)</label>
|
||||
<p className="help-text">Recipes with these categories will be automatically added to this cookbook</p>
|
||||
<div className="filter-chips">
|
||||
{autoFilterCategories.map(category => (
|
||||
<span key={category} className="filter-chip">
|
||||
{category}
|
||||
<button type="button" onClick={() => handleRemoveCategory(category)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCategory())}
|
||||
placeholder="Add category"
|
||||
list="available-categories"
|
||||
/>
|
||||
<button type="button" onClick={handleAddCategory} className="btn-add-filter">+</button>
|
||||
</div>
|
||||
<datalist id="available-categories">
|
||||
{availableCategories.map(category => (
|
||||
<option key={category} value={category} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Auto-Add Tags (Optional)</label>
|
||||
<p className="help-text">Recipes with these tags will be automatically added to this cookbook</p>
|
||||
<div className="filter-chips">
|
||||
{autoFilterTags.map(tag => (
|
||||
<span key={tag} className="filter-chip">
|
||||
{tag}
|
||||
<button type="button" onClick={() => handleRemoveTag(tag)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
||||
placeholder="Add tag"
|
||||
list="available-tags-modal"
|
||||
/>
|
||||
<button type="button" onClick={handleAddTag} className="btn-add-filter">+</button>
|
||||
</div>
|
||||
<datalist id="available-tags-modal">
|
||||
{availableTags.map(tag => (
|
||||
<option key={tag.id} value={tag.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" onClick={() => setShowCreateModal(false)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Cookbooks;
|
||||
@@ -23,6 +23,7 @@ function CookingMode() {
|
||||
const [ingredientsCollapsed, setIngredientsCollapsed] = useState(false);
|
||||
const [collapsedSteps, setCollapsedSteps] = useState<Set<number>>(new Set());
|
||||
const [autoCollapseOnComplete, setAutoCollapseOnComplete] = useState(false);
|
||||
const [fontSize, setFontSize] = useState<number>(5); // 1-10 scale, default to middle
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -136,7 +137,7 @@ function CookingMode() {
|
||||
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
|
||||
: ingredientStr;
|
||||
|
||||
return ingredient.notes ? `${displayStr} (${ingredient.notes})` : displayStr;
|
||||
return displayStr;
|
||||
};
|
||||
|
||||
const getInstructionsWithIngredients = (): InstructionWithIngredients[] => {
|
||||
@@ -216,7 +217,7 @@ function CookingMode() {
|
||||
const allIngredients = getAllIngredients();
|
||||
|
||||
return (
|
||||
<div className={`cooking-mode ${isFullscreen ? 'fullscreen' : ''}`}>
|
||||
<div className={`cooking-mode ${isFullscreen ? 'fullscreen' : ''} font-size-${fontSize}`}>
|
||||
<div className="cooking-mode-header">
|
||||
<div className="cooking-mode-title">
|
||||
<h1>{recipe.title}</h1>
|
||||
@@ -253,6 +254,32 @@ function CookingMode() {
|
||||
Auto collapse completed steps
|
||||
</label>
|
||||
|
||||
<div className="font-size-control">
|
||||
<button
|
||||
onClick={() => setFontSize(Math.max(1, fontSize - 1))}
|
||||
disabled={fontSize <= 1}
|
||||
className="font-size-btn"
|
||||
title="Decrease font size"
|
||||
>
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFontSize(5)}
|
||||
className="font-size-btn reset"
|
||||
title="Reset font size to default"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFontSize(Math.min(10, fontSize + 1))}
|
||||
disabled={fontSize >= 10}
|
||||
className="font-size-btn"
|
||||
title="Increase font size"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={() => navigate(`/recipes/${id}/edit`)} className="manage-btn">
|
||||
✏️ Edit Recipe
|
||||
</button>
|
||||
@@ -275,50 +302,54 @@ function CookingMode() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Ingredients Section */}
|
||||
<div className="cooking-mode-ingredients-section">
|
||||
<h2
|
||||
onClick={() => setIngredientsCollapsed(!ingredientsCollapsed)}
|
||||
className="collapsible-header"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className="collapse-icon">{ingredientsCollapsed ? '▶' : '▼'}</span>
|
||||
Ingredients
|
||||
</h2>
|
||||
{/* Main content area - side-by-side layout on desktop */}
|
||||
<div className="cooking-mode-content">
|
||||
{/* Ingredients Section */}
|
||||
<div className="cooking-mode-ingredients-section">
|
||||
<h2
|
||||
onClick={() => setIngredientsCollapsed(!ingredientsCollapsed)}
|
||||
className="collapsible-header"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className="collapse-icon">{ingredientsCollapsed ? '▶' : '▼'}</span>
|
||||
Ingredients
|
||||
</h2>
|
||||
|
||||
{!ingredientsCollapsed && (
|
||||
<>
|
||||
{recipe.sections && recipe.sections.length > 0 ? (
|
||||
<>
|
||||
{recipe.sections.map(section => (
|
||||
<div key={section.id} className="ingredient-section">
|
||||
<h3>{section.name}</h3>
|
||||
{section.timing && <p className="section-timing">{section.timing}</p>}
|
||||
<ul>
|
||||
{section.ingredients?.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
{getScaledIngredientText(ingredient)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<ul>
|
||||
{allIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
{getScaledIngredientText(ingredient)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!ingredientsCollapsed && (
|
||||
<>
|
||||
{recipe.sections && recipe.sections.length > 0 ? (
|
||||
<>
|
||||
{recipe.sections.map(section => (
|
||||
<div key={section.id} className="ingredient-section">
|
||||
<h3>{section.name}</h3>
|
||||
{section.timing && <p className="section-timing">{section.timing}</p>}
|
||||
<ul>
|
||||
{section.ingredients?.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
<div className="ingredient-main">{getScaledIngredientText(ingredient)}</div>
|
||||
{ingredient.notes && <div className="ingredient-note">{ingredient.notes}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<ul>
|
||||
{allIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
<div className="ingredient-main">{getScaledIngredientText(ingredient)}</div>
|
||||
{ingredient.notes && <div className="ingredient-note">{ingredient.notes}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Instructions Section */}
|
||||
<div className="cooking-mode-instructions">
|
||||
{/* Instructions Section */}
|
||||
<div className="cooking-mode-instructions">
|
||||
<h2>Instructions</h2>
|
||||
|
||||
{recipe.sections && recipe.sections.length > 0 ? (
|
||||
@@ -383,9 +414,8 @@ function CookingMode() {
|
||||
<ul>
|
||||
{matchedIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id} className="ingredient-item">
|
||||
<span className="ingredient-text">
|
||||
{getScaledIngredientText(ingredient)}
|
||||
</span>
|
||||
<div className="ingredient-main">{getScaledIngredientText(ingredient)}</div>
|
||||
{ingredient.notes && <div className="ingredient-note">{ingredient.notes}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -448,9 +478,8 @@ function CookingMode() {
|
||||
<ul>
|
||||
{matchedIngredients.map((ingredient) => (
|
||||
<li key={ingredient.id} className="ingredient-item">
|
||||
<span className="ingredient-text">
|
||||
{getScaledIngredientText(ingredient)}
|
||||
</span>
|
||||
<div className="ingredient-main">{getScaledIngredientText(ingredient)}</div>
|
||||
{ingredient.notes && <div className="ingredient-note">{ingredient.notes}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -463,6 +492,7 @@ function CookingMode() {
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cooking-mode-footer">
|
||||
|
||||
389
packages/web/src/pages/EditCookbook.tsx
Normal file
389
packages/web/src/pages/EditCookbook.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Tag } from '@basil/shared';
|
||||
import { cookbooksApi, tagsApi } from '../services/api';
|
||||
import '../styles/EditCookbook.css';
|
||||
|
||||
function EditCookbook() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [coverImageUrl, setCoverImageUrl] = useState('');
|
||||
const [imageUrlInput, setImageUrlInput] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [autoFilterCategories, setAutoFilterCategories] = useState<string[]>([]);
|
||||
const [autoFilterTags, setAutoFilterTags] = useState<string[]>([]);
|
||||
|
||||
const [categoryInput, setCategoryInput] = useState('');
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadCookbook(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadCookbook = async (cookbookId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [cookbookResponse, tagsResponse] = await Promise.all([
|
||||
cookbooksApi.getById(cookbookId),
|
||||
tagsApi.getAll()
|
||||
]);
|
||||
|
||||
const cookbook = cookbookResponse.data;
|
||||
if (cookbook) {
|
||||
setName(cookbook.name);
|
||||
setDescription(cookbook.description || '');
|
||||
setCoverImageUrl(cookbook.coverImageUrl || '');
|
||||
setAutoFilterCategories(cookbook.autoFilterCategories || []);
|
||||
setAutoFilterTags(cookbook.autoFilterTags || []);
|
||||
}
|
||||
|
||||
setAvailableTags(tagsResponse.data || []);
|
||||
|
||||
// Extract unique categories from cookbook's recipes
|
||||
const categories = new Set<string>();
|
||||
if (cookbook && 'recipes' in cookbook) {
|
||||
(cookbook as any).recipes.forEach((recipe: any) => {
|
||||
if (recipe.categories) {
|
||||
recipe.categories.forEach((cat: string) => categories.add(cat));
|
||||
}
|
||||
});
|
||||
}
|
||||
setAvailableCategories(Array.from(categories).sort());
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to load cookbook:', err);
|
||||
setError('Failed to load cookbook');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!id || !name.trim()) {
|
||||
alert('Cookbook name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await cookbooksApi.update(id, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
coverImageUrl: coverImageUrl === '' ? '' : (coverImageUrl || undefined),
|
||||
autoFilterCategories,
|
||||
autoFilterTags
|
||||
});
|
||||
|
||||
navigate(`/cookbooks/${id}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to update cookbook:', err);
|
||||
alert('Failed to update cookbook');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCategory = () => {
|
||||
const trimmed = categoryInput.trim();
|
||||
if (trimmed && !autoFilterCategories.includes(trimmed)) {
|
||||
setAutoFilterCategories([...autoFilterCategories, trimmed]);
|
||||
setCategoryInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (category: string) => {
|
||||
setAutoFilterCategories(autoFilterCategories.filter(c => c !== category));
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
const trimmed = tagInput.trim();
|
||||
if (trimmed && !autoFilterTags.includes(trimmed)) {
|
||||
setAutoFilterTags([...autoFilterTags, trimmed]);
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setAutoFilterTags(autoFilterTags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setSelectedFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadImage = async () => {
|
||||
if (!id || !selectedFile) return;
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const response = await cookbooksApi.uploadImage(id, selectedFile);
|
||||
if (response.data?.url) {
|
||||
setCoverImageUrl(response.data.url);
|
||||
setSelectedFile(null);
|
||||
setImageUrlInput(''); // Clear URL input after upload
|
||||
alert('Image uploaded successfully!');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to upload image:', err);
|
||||
alert('Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFromUrl = async () => {
|
||||
if (!id || !imageUrlInput.trim()) return;
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const response = await cookbooksApi.uploadImageFromUrl(id, imageUrlInput.trim());
|
||||
if (response.data?.url) {
|
||||
setCoverImageUrl(response.data.url);
|
||||
setImageUrlInput(''); // Clear URL input after download
|
||||
alert('Image downloaded and saved successfully!');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to download image from URL:', err);
|
||||
alert('Failed to download image from URL');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="edit-cookbook-page">
|
||||
<div className="loading">Loading cookbook...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="edit-cookbook-page">
|
||||
<div className="error">{error}</div>
|
||||
<button onClick={() => navigate('/cookbooks')}>← Back to Cookbooks</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="edit-cookbook-page">
|
||||
<header className="page-header">
|
||||
<button onClick={() => navigate(`/cookbooks/${id}`)} className="back-btn">
|
||||
← Back to Cookbook
|
||||
</button>
|
||||
<h1>Edit Cookbook</h1>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="edit-cookbook-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="cookbook-name">Name *</label>
|
||||
<input
|
||||
id="cookbook-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Family Favorites, Holiday Recipes"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="cookbook-description">Description</label>
|
||||
<textarea
|
||||
id="cookbook-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe this cookbook..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Cover Image</label>
|
||||
{coverImageUrl && (
|
||||
<div className="image-preview">
|
||||
<img src={coverImageUrl} alt="Cover preview" />
|
||||
<p className="image-url">Current image: {coverImageUrl.startsWith('http') ? 'External URL' : 'Uploaded file'}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (window.confirm('Remove the current cover image? This will save immediately.')) {
|
||||
try {
|
||||
setUploading(true);
|
||||
await cookbooksApi.update(id!, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
coverImageUrl: '',
|
||||
autoFilterCategories,
|
||||
autoFilterTags
|
||||
});
|
||||
setCoverImageUrl('');
|
||||
alert('Image removed successfully!');
|
||||
} catch (err) {
|
||||
console.error('Failed to remove image:', err);
|
||||
alert('Failed to remove image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="btn-secondary"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
disabled={uploading}
|
||||
>
|
||||
Remove Image
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="image-upload-section">
|
||||
<p className="help-text" style={{ fontWeight: 600, marginBottom: '0.75rem' }}>
|
||||
Upload a new image:
|
||||
</p>
|
||||
<label htmlFor="image-file" className="file-input-label">
|
||||
Choose Image File
|
||||
</label>
|
||||
<input
|
||||
id="image-file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="file-input"
|
||||
/>
|
||||
{selectedFile && (
|
||||
<div className="file-selected">
|
||||
<span>{selectedFile.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadImage}
|
||||
disabled={uploading}
|
||||
className="btn-upload"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="help-text" style={{ marginTop: '1rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
||||
Or download from a URL:
|
||||
</p>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
id="cover-image-url"
|
||||
type="text"
|
||||
value={imageUrlInput}
|
||||
onChange={(e) => setImageUrlInput(e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadFromUrl}
|
||||
disabled={uploading || !imageUrlInput.trim()}
|
||||
className="btn-upload"
|
||||
>
|
||||
{uploading ? 'Downloading...' : 'Download'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Auto-Add Categories</label>
|
||||
<p className="help-text">
|
||||
Recipes with these categories will be automatically added to this cookbook
|
||||
</p>
|
||||
<div className="filter-chips">
|
||||
{autoFilterCategories.map(category => (
|
||||
<span key={category} className="filter-chip">
|
||||
{category}
|
||||
<button type="button" onClick={() => handleRemoveCategory(category)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddCategory())}
|
||||
placeholder="Add category"
|
||||
list="available-categories"
|
||||
/>
|
||||
<button type="button" onClick={handleAddCategory} className="btn-add-filter">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<datalist id="available-categories">
|
||||
{availableCategories.map(category => (
|
||||
<option key={category} value={category} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Auto-Add Tags</label>
|
||||
<p className="help-text">
|
||||
Recipes with these tags will be automatically added to this cookbook
|
||||
</p>
|
||||
<div className="filter-chips">
|
||||
{autoFilterTags.map(tag => (
|
||||
<span key={tag} className="filter-chip">
|
||||
{tag}
|
||||
<button type="button" onClick={() => handleRemoveTag(tag)}>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="input-with-button">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
||||
placeholder="Add tag"
|
||||
list="available-tags-edit"
|
||||
/>
|
||||
<button type="button" onClick={handleAddTag} className="btn-add-filter">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<datalist id="available-tags-edit">
|
||||
{availableTags.map(tag => (
|
||||
<option key={tag.id} value={tag.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={() => navigate(`/cookbooks/${id}`)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditCookbook;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Recipe } from '@basil/shared';
|
||||
import { recipesApi } from '../services/api';
|
||||
import { Recipe, Cookbook } from '@basil/shared';
|
||||
import { recipesApi, cookbooksApi } from '../services/api';
|
||||
import { scaleIngredientString } from '../utils/ingredientParser';
|
||||
|
||||
function RecipeDetail() {
|
||||
@@ -11,6 +11,9 @@ function RecipeDetail() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentServings, setCurrentServings] = useState<number | null>(null);
|
||||
const [showCookbookModal, setShowCookbookModal] = useState(false);
|
||||
const [cookbooks, setCookbooks] = useState<Cookbook[]>([]);
|
||||
const [loadingCookbooks, setLoadingCookbooks] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -64,6 +67,37 @@ function RecipeDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCookbookModal = async () => {
|
||||
setLoadingCookbooks(true);
|
||||
setShowCookbookModal(true);
|
||||
try {
|
||||
const response = await cookbooksApi.getAll();
|
||||
setCookbooks(response.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load cookbooks:', err);
|
||||
alert('Failed to load cookbooks');
|
||||
} finally {
|
||||
setLoadingCookbooks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCookbook = async (cookbookId: string) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await cookbooksApi.addRecipe(cookbookId, id);
|
||||
alert('Recipe added to cookbook!');
|
||||
setShowCookbookModal(false);
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 400) {
|
||||
alert('Recipe is already in this cookbook');
|
||||
} else {
|
||||
console.error('Failed to add recipe to cookbook:', err);
|
||||
alert('Failed to add recipe to cookbook');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading recipe...</div>;
|
||||
}
|
||||
@@ -81,6 +115,9 @@ function RecipeDetail() {
|
||||
<div className="recipe-actions">
|
||||
<button onClick={() => navigate('/')}>← Back to Recipes</button>
|
||||
<div className="recipe-actions-right">
|
||||
<button onClick={handleOpenCookbookModal} style={{ backgroundColor: '#1976d2' }}>
|
||||
📚 Add to Cookbook
|
||||
</button>
|
||||
<button onClick={() => navigate(`/recipes/${id}/cook`)} style={{ backgroundColor: '#2e7d32' }}>
|
||||
👨🍳 Cooking Mode
|
||||
</button>
|
||||
@@ -236,6 +273,43 @@ function RecipeDetail() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add to Cookbook Modal */}
|
||||
{showCookbookModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowCookbookModal(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Add to Cookbook</h2>
|
||||
{loadingCookbooks ? (
|
||||
<p>Loading cookbooks...</p>
|
||||
) : cookbooks.length === 0 ? (
|
||||
<div>
|
||||
<p>You don't have any cookbooks yet.</p>
|
||||
<button onClick={() => navigate('/')} className="btn-primary">
|
||||
Create a Cookbook
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cookbook-list">
|
||||
{cookbooks.map((cookbook) => (
|
||||
<div key={cookbook.id} className="cookbook-item" onClick={() => handleAddToCookbook(cookbook.id)}>
|
||||
<div>
|
||||
<h3>{cookbook.name}</h3>
|
||||
{cookbook.description && <p>{cookbook.description}</p>}
|
||||
<span className="recipe-count">{cookbook.recipeCount || 0} recipes</span>
|
||||
</div>
|
||||
<button className="btn-add">+</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button onClick={() => setShowCookbookModal(false)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
const [cookTime, setCookTime] = useState(initialRecipe?.cookTime?.toString() || '');
|
||||
const [servings, setServings] = useState(initialRecipe?.servings?.toString() || '');
|
||||
const [cuisine, setCuisine] = useState(initialRecipe?.cuisine || '');
|
||||
const [category, setCategory] = useState(initialRecipe?.category || '');
|
||||
const [categories, setCategories] = useState<string[]>(initialRecipe?.categories || []);
|
||||
const [categoryInput, setCategoryInput] = useState('');
|
||||
|
||||
// Image handling
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
@@ -248,6 +249,29 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Category management
|
||||
const handleAddCategory = () => {
|
||||
const trimmedCategory = categoryInput.trim();
|
||||
if (!trimmedCategory) return;
|
||||
if (categories.includes(trimmedCategory)) {
|
||||
setCategoryInput('');
|
||||
return;
|
||||
}
|
||||
setCategories([...categories, trimmedCategory]);
|
||||
setCategoryInput('');
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (categoryToRemove: string) => {
|
||||
setCategories(categories.filter(cat => cat !== categoryToRemove));
|
||||
};
|
||||
|
||||
const handleCategoryInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCategory();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -258,7 +282,7 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
cookTime: cookTime ? parseInt(cookTime) : undefined,
|
||||
servings: servings ? parseInt(servings) : undefined,
|
||||
cuisine: cuisine || undefined,
|
||||
category: category || undefined,
|
||||
categories: categories.length > 0 ? categories : undefined,
|
||||
};
|
||||
|
||||
if (useSections) {
|
||||
@@ -338,25 +362,51 @@ function RecipeForm({ initialRecipe, onSubmit, onCancel }: RecipeFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="cuisine">Cuisine</label>
|
||||
<input
|
||||
type="text"
|
||||
id="cuisine"
|
||||
value={cuisine}
|
||||
onChange={(e) => setCuisine(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="cuisine">Cuisine</label>
|
||||
<input
|
||||
type="text"
|
||||
id="cuisine"
|
||||
value={cuisine}
|
||||
onChange={(e) => setCuisine(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="category">Category</label>
|
||||
<input
|
||||
type="text"
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
/>
|
||||
<div className="form-group">
|
||||
<label htmlFor="categories">Categories</label>
|
||||
<div className="tags-input-container">
|
||||
<div className="tags-list">
|
||||
{categories.map((category) => (
|
||||
<span key={category} className="tag">
|
||||
{category}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCategory(category)}
|
||||
className="tag-remove"
|
||||
title="Remove category"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="tag-input-row">
|
||||
<input
|
||||
type="text"
|
||||
id="categories"
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={handleCategoryInputKeyDown}
|
||||
placeholder="Add a category and press Enter"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCategory}
|
||||
className="btn-add-tag"
|
||||
>
|
||||
Add Category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Recipe, Ingredient, Instruction, RecipeSection } from '@basil/shared';
|
||||
import { recipesApi } from '../services/api';
|
||||
import { Recipe, Ingredient, Instruction, RecipeSection, Tag } from '@basil/shared';
|
||||
import { recipesApi, tagsApi } from '../services/api';
|
||||
import '../styles/UnifiedRecipeEdit.css';
|
||||
|
||||
interface MappingChange {
|
||||
@@ -26,7 +26,11 @@ function UnifiedEditRecipe() {
|
||||
const [cookTime, setCookTime] = useState('');
|
||||
const [servings, setServings] = useState('');
|
||||
const [cuisine, setCuisine] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [recipeCategories, setRecipeCategories] = useState<string[]>([]);
|
||||
const [categoryInput, setCategoryInput] = useState('');
|
||||
const [recipeTags, setRecipeTags] = useState<string[]>([]);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [availableTags, setAvailableTags] = useState<Tag[]>([]);
|
||||
|
||||
// Section mode
|
||||
const [useSections, setUseSections] = useState(false);
|
||||
@@ -60,8 +64,18 @@ function UnifiedEditRecipe() {
|
||||
if (id) {
|
||||
loadRecipe(id);
|
||||
}
|
||||
loadAvailableTags();
|
||||
}, [id]);
|
||||
|
||||
const loadAvailableTags = async () => {
|
||||
try {
|
||||
const response = await tagsApi.getAll();
|
||||
setAvailableTags(response.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to load tags:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecipe = async (recipeId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -77,7 +91,8 @@ function UnifiedEditRecipe() {
|
||||
setCookTime(loadedRecipe.cookTime?.toString() || '');
|
||||
setServings(loadedRecipe.servings?.toString() || '');
|
||||
setCuisine(loadedRecipe.cuisine || '');
|
||||
setCategory(loadedRecipe.category || '');
|
||||
setRecipeCategories(loadedRecipe.categories || []);
|
||||
setRecipeTags(loadedRecipe.tags || []);
|
||||
|
||||
// Set sections or simple mode
|
||||
const hasSections = !!(loadedRecipe.sections && loadedRecipe.sections.length > 0);
|
||||
@@ -450,7 +465,8 @@ function UnifiedEditRecipe() {
|
||||
cookTime: cookTime ? parseInt(cookTime) : undefined,
|
||||
servings: servings ? parseInt(servings) : undefined,
|
||||
cuisine: cuisine || undefined,
|
||||
category: category || undefined,
|
||||
categories: recipeCategories.length > 0 ? recipeCategories : undefined,
|
||||
tags: recipeTags,
|
||||
};
|
||||
|
||||
if (useSections) {
|
||||
@@ -569,6 +585,68 @@ function UnifiedEditRecipe() {
|
||||
navigate(`/recipes/${id}`);
|
||||
};
|
||||
|
||||
// Category management functions
|
||||
const handleAddCategory = (categoryName: string) => {
|
||||
const trimmedCategory = categoryName.trim();
|
||||
if (!trimmedCategory) return;
|
||||
|
||||
if (recipeCategories.includes(trimmedCategory)) {
|
||||
setCategoryInput('');
|
||||
return; // Category already exists
|
||||
}
|
||||
|
||||
setRecipeCategories([...recipeCategories, trimmedCategory]);
|
||||
setCategoryInput('');
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleRemoveCategory = (categoryToRemove: string) => {
|
||||
setRecipeCategories(recipeCategories.filter(cat => cat !== categoryToRemove));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleCategoryInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddCategory(categoryInput);
|
||||
}
|
||||
};
|
||||
|
||||
// Tag management functions
|
||||
const handleAddTag = async (tagName: string) => {
|
||||
const trimmedTag = tagName.trim();
|
||||
if (!trimmedTag) return;
|
||||
|
||||
if (recipeTags.includes(trimmedTag)) {
|
||||
setTagInput('');
|
||||
return; // Tag already exists
|
||||
}
|
||||
|
||||
// Create or find tag in database (for autocomplete purposes)
|
||||
try {
|
||||
await tagsApi.createOrFind(trimmedTag);
|
||||
await loadAvailableTags(); // Refresh available tags
|
||||
} catch (err) {
|
||||
console.error('Failed to create tag:', err);
|
||||
}
|
||||
|
||||
setRecipeTags([...recipeTags, trimmedTag]);
|
||||
setTagInput('');
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setRecipeTags(recipeTags.filter(tag => tag !== tagToRemove));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTag(tagInput);
|
||||
}
|
||||
};
|
||||
|
||||
const getIngredientText = (ingredient: Ingredient): string => {
|
||||
let ingredientStr = '';
|
||||
if (ingredient.amount && ingredient.unit) {
|
||||
@@ -743,20 +821,93 @@ function UnifiedEditRecipe() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="category">Category</label>
|
||||
<input
|
||||
type="text"
|
||||
id="category"
|
||||
value={category}
|
||||
onChange={(e) => {
|
||||
setCategory(e.target.value);
|
||||
setHasChanges(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="categories">Categories</label>
|
||||
<div className="tags-input-container">
|
||||
<div className="tags-list">
|
||||
{recipeCategories.map((category) => (
|
||||
<span key={category} className="tag">
|
||||
{category}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveCategory(category)}
|
||||
className="tag-remove"
|
||||
title="Remove category"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="tag-input-row">
|
||||
<input
|
||||
type="text"
|
||||
id="categories"
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={handleCategoryInputKeyDown}
|
||||
placeholder="Add a category and press Enter"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddCategory(categoryInput)}
|
||||
className="btn-add-tag"
|
||||
>
|
||||
Add Category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="tags">Tags</label>
|
||||
<div className="tags-input-container">
|
||||
<div className="tags-list">
|
||||
{recipeTags.map((tag) => (
|
||||
<span key={tag} className="tag">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="tag-remove"
|
||||
title="Remove tag"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="tag-input-row">
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
placeholder="Add a tag and press Enter"
|
||||
list="available-tags"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTag(tagInput)}
|
||||
className="btn-add-tag"
|
||||
>
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
<datalist id="available-tags">
|
||||
{availableTags.map((tag) => (
|
||||
<option key={tag.id} value={tag.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<p className="field-help">Add tags to categorize your recipe (e.g., "Quick", "Vegetarian", "Dessert")</p>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div className="form-group image-upload-section">
|
||||
<label>Recipe Image</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { Recipe, RecipeImportRequest, RecipeImportResponse, ApiResponse, PaginatedResponse } from '@basil/shared';
|
||||
import { Recipe, RecipeImportRequest, RecipeImportResponse, ApiResponse, PaginatedResponse, Cookbook, CookbookWithRecipes, Tag } from '@basil/shared';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -73,4 +73,72 @@ export const recipesApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const cookbooksApi = {
|
||||
getAll: async (): Promise<ApiResponse<Cookbook[]>> => {
|
||||
const response = await api.get('/cookbooks');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ApiResponse<CookbookWithRecipes>> => {
|
||||
const response = await api.get(`/cookbooks/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (cookbook: { name: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[] }): Promise<ApiResponse<Cookbook>> => {
|
||||
const response = await api.post('/cookbooks', cookbook);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, cookbook: { name?: string; description?: string; coverImageUrl?: string; autoFilterCategories?: string[]; autoFilterTags?: string[] }): Promise<ApiResponse<Cookbook>> => {
|
||||
const response = await api.put(`/cookbooks/${id}`, cookbook);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.delete(`/cookbooks/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addRecipe: async (cookbookId: string, recipeId: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.post(`/cookbooks/${cookbookId}/recipes/${recipeId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeRecipe: async (cookbookId: string, recipeId: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.delete(`/cookbooks/${cookbookId}/recipes/${recipeId}`);
|
||||
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(`/cookbooks/${id}/image`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadImageFromUrl: async (id: string, url: string): Promise<ApiResponse<{ url: string }>> => {
|
||||
const response = await api.post(`/cookbooks/${id}/image-from-url`, { url });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const tagsApi = {
|
||||
getAll: async (): Promise<ApiResponse<Tag[]>> => {
|
||||
const response = await api.get('/tags');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createOrFind: async (name: string): Promise<ApiResponse<Tag>> => {
|
||||
const response = await api.post('/tags', { name });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<ApiResponse<void>> => {
|
||||
const response = await api.delete(`/tags/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
433
packages/web/src/styles/CookbookDetail.css
Normal file
433
packages/web/src/styles/CookbookDetail.css
Normal file
@@ -0,0 +1,433 @@
|
||||
/* Cookbook Detail Page */
|
||||
|
||||
.cookbook-detail-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.cookbook-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2e7d32;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: #1b5e20;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cookbook-title-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cookbook-title-section h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #2e7d32;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.cookbook-description {
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
margin: 0 0 0.5rem 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.recipe-count {
|
||||
font-size: 0.95rem;
|
||||
color: #757575;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cookbook-actions {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
|
||||
/* Quick Filters Section */
|
||||
|
||||
.quick-filters-section {
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 2px solid #2e7d32;
|
||||
box-shadow: 0 4px 12px rgba(46, 125, 50, 0.15);
|
||||
}
|
||||
|
||||
.quick-filters-section h3 {
|
||||
color: #1b5e20;
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 1rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quick-tag-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-tag-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: 2px solid #2e7d32;
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #2e7d32;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.quick-tag-btn:hover {
|
||||
background-color: #f1f8e9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.quick-tag-btn.active {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(46, 125, 50, 0.3);
|
||||
}
|
||||
|
||||
.quick-tag-btn .checkmark {
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Filters Section */
|
||||
|
||||
.filters-section {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #424242;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tag-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-filter {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tag-filter:hover {
|
||||
border-color: #2e7d32;
|
||||
background-color: #f1f8e9;
|
||||
}
|
||||
|
||||
.tag-filter.active {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filter-row .filter-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
|
||||
.btn-clear-filters {
|
||||
background-color: #757575;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-clear-filters:hover {
|
||||
background-color: #616161;
|
||||
}
|
||||
|
||||
/* Results Section */
|
||||
|
||||
.results-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 0.95rem;
|
||||
color: #757575;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.recipe-card > div:first-child {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recipe-image-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, #ffb74d 0%, #ff9800 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.recipe-info {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.recipe-info h3 {
|
||||
font-size: 1.2rem;
|
||||
color: #212121;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.recipe-info .description {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recipe-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.recipe-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.recipe-tags .tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remove-recipe-btn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(211, 47, 47, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.recipe-card:hover .remove-recipe-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-recipe-btn:hover {
|
||||
background-color: #b71c1c;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #757575;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Loading and Error */
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.1rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cookbook-detail-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cookbook-title-section h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-row .filter-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
418
packages/web/src/styles/Cookbooks.css
Normal file
418
packages/web/src/styles/Cookbooks.css
Normal file
@@ -0,0 +1,418 @@
|
||||
/* Cookbooks Page Styles */
|
||||
|
||||
.cookbooks-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.cookbooks-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.cookbooks-header h1 {
|
||||
font-size: 2.5rem;
|
||||
color: #2e7d32;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Cookbooks Section */
|
||||
.cookbooks-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.cookbooks-section h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #1b5e20;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.cookbooks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.cookbook-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.cookbook-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.cookbook-cover {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cookbook-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, #81c784 0%, #4caf50 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.cookbook-info {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.cookbook-info h3 {
|
||||
font-size: 1.3rem;
|
||||
color: #212121;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.cookbook-info .description {
|
||||
font-size: 0.95rem;
|
||||
color: #666;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cookbook-info .recipe-count {
|
||||
font-size: 0.9rem;
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Recent Recipes Section */
|
||||
.recent-recipes-section {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #1b5e20;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recipes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recipe-image-placeholder {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: linear-gradient(135deg, #ffb74d 0%, #ff9800 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.recipe-info {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.recipe-info h3 {
|
||||
font-size: 1.2rem;
|
||||
color: #212121;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.recipe-info .description {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recipe-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: #2e7d32;
|
||||
border: 2px solid #2e7d32;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #f1f8e9;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2e7d32;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: #1b5e20;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #424242;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.1rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Auto-Filter Configuration */
|
||||
.help-text {
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-radius: 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2e7d32;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-chip button:hover {
|
||||
background-color: rgba(46, 125, 50, 0.2);
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-add-filter {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-add-filter:hover {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.cookbooks-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cookbooks-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cookbooks-grid,
|
||||
.recipes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,20 @@
|
||||
background-color: #fafafa;
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Font Size Scale - 10 levels - Only affects content, not controls */
|
||||
.cooking-mode.font-size-1 { --content-font-scale: 0.7; }
|
||||
.cooking-mode.font-size-2 { --content-font-scale: 0.8; }
|
||||
.cooking-mode.font-size-3 { --content-font-scale: 0.9; }
|
||||
.cooking-mode.font-size-4 { --content-font-scale: 0.95; }
|
||||
.cooking-mode.font-size-5 { --content-font-scale: 1.0; }
|
||||
.cooking-mode.font-size-6 { --content-font-scale: 1.1; }
|
||||
.cooking-mode.font-size-7 { --content-font-scale: 1.2; }
|
||||
.cooking-mode.font-size-8 { --content-font-scale: 1.3; }
|
||||
.cooking-mode.font-size-9 { --content-font-scale: 1.4; }
|
||||
.cooking-mode.font-size-10 { --content-font-scale: 1.5; }
|
||||
|
||||
/* Fullscreen mode */
|
||||
.cooking-mode.fullscreen {
|
||||
position: fixed;
|
||||
@@ -31,7 +42,7 @@
|
||||
}
|
||||
|
||||
.cooking-mode-title h1 {
|
||||
font-size: 2.5rem;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
color: #1b5e20;
|
||||
line-height: 1.2;
|
||||
@@ -45,8 +56,8 @@
|
||||
}
|
||||
|
||||
.cooking-mode-controls button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -121,7 +132,7 @@
|
||||
|
||||
.servings-control span {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -136,7 +147,7 @@
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.toggle-inline-ingredients input[type="checkbox"] {
|
||||
@@ -151,7 +162,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
@@ -163,12 +174,33 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Main content area - side-by-side layout on desktop */
|
||||
.cooking-mode-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.cooking-mode-content {
|
||||
grid-template-columns: 350px 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ingredients section */
|
||||
.cooking-mode-ingredients-section {
|
||||
background-color: white;
|
||||
padding: 2rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
border: 2px solid #2e7d32;
|
||||
}
|
||||
|
||||
@@ -181,16 +213,18 @@
|
||||
|
||||
.cooking-mode-ingredients-section h2 {
|
||||
margin-top: 0;
|
||||
font-size: 2rem;
|
||||
font-size: calc(1.5rem * var(--content-font-scale, 1));
|
||||
color: #2e7d32;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section h3 {
|
||||
font-size: 1.5rem;
|
||||
font-size: calc(1.2rem * var(--content-font-scale, 1));
|
||||
color: #1b5e20;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section ul {
|
||||
@@ -200,56 +234,71 @@
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section li {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
padding: 0.5rem 0.5rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cooking-mode-ingredients-section li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ingredient-main {
|
||||
font-size: calc(1.05rem * var(--content-font-scale, 1));
|
||||
line-height: 1.4;
|
||||
color: #212121;
|
||||
}
|
||||
|
||||
.ingredient-note {
|
||||
font-size: calc(0.9rem * var(--content-font-scale, 1));
|
||||
line-height: 1.3;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-top: 0.15rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ingredient-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Instructions section */
|
||||
.cooking-mode-instructions {
|
||||
margin-bottom: 3rem;
|
||||
/* No bottom margin needed - handled by grid gap */
|
||||
}
|
||||
|
||||
.cooking-mode-instructions h2 {
|
||||
font-size: 2rem;
|
||||
font-size: calc(1.5rem * var(--content-font-scale, 1));
|
||||
color: #2e7d32;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Section headers (for multi-section recipes) */
|
||||
.instruction-section {
|
||||
margin-bottom: 3rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #2e7d32;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 1.75rem;
|
||||
font-size: calc(1.3rem * var(--content-font-scale, 1));
|
||||
margin: 0;
|
||||
color: #1b5e20;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.section-timing {
|
||||
font-size: 1rem;
|
||||
font-size: calc(0.9rem * var(--content-font-scale, 1));
|
||||
color: #666;
|
||||
background-color: #fff3e0;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -257,9 +306,9 @@
|
||||
/* Individual instruction steps */
|
||||
.instruction-step {
|
||||
background-color: white;
|
||||
padding: 1.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
@@ -279,7 +328,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.step-checkbox {
|
||||
@@ -291,48 +340,50 @@
|
||||
}
|
||||
|
||||
.step-checkbox input[type="checkbox"] {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-size: 1.5rem;
|
||||
font-size: calc(1.2rem * var(--content-font-scale, 1));
|
||||
font-weight: 700;
|
||||
color: #2e7d32;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.instruction-timing {
|
||||
font-size: 1rem;
|
||||
font-size: calc(0.9rem * var(--content-font-scale, 1));
|
||||
color: #d84315;
|
||||
background-color: #fff3e0;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.8;
|
||||
font-size: calc(1.1rem * var(--content-font-scale, 1));
|
||||
line-height: 1.5;
|
||||
color: #212121;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Inline ingredients */
|
||||
.inline-ingredients {
|
||||
background-color: #f1f8e9;
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
border-left: 4px solid #2e7d32;
|
||||
}
|
||||
|
||||
.inline-ingredients strong {
|
||||
color: #1b5e20;
|
||||
font-size: 1.1rem;
|
||||
font-size: calc(1rem * var(--content-font-scale, 1));
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.inline-ingredients ul {
|
||||
@@ -342,9 +393,9 @@
|
||||
}
|
||||
|
||||
.inline-ingredients li {
|
||||
padding: 0.4rem 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.25rem 0;
|
||||
font-size: calc(1rem * var(--content-font-scale, 1));
|
||||
line-height: 1.4;
|
||||
color: #33691e;
|
||||
}
|
||||
|
||||
@@ -367,7 +418,7 @@
|
||||
|
||||
.exit-btn-large {
|
||||
padding: 1rem 3rem;
|
||||
font-size: 1.25rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
@@ -464,7 +515,7 @@
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.toggle-auto-collapse input[type="checkbox"] {
|
||||
@@ -472,3 +523,57 @@
|
||||
height: 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Font Size Control - Fixed size to prevent jumping */
|
||||
.font-size-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background-color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.font-size-btn {
|
||||
height: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 2px solid #2e7d32;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
color: #2e7d32;
|
||||
font-weight: 700;
|
||||
font-size: 1rem !important; /* Fixed size - don't scale */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.font-size-btn.reset {
|
||||
font-size: 0.85rem !important;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.font-size-btn:hover:not(:disabled) {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.font-size-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
border-color: #ccc;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.font-size-label {
|
||||
font-size: 0.9rem !important; /* Fixed size - don't scale */
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
white-space: nowrap;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
308
packages/web/src/styles/EditCookbook.css
Normal file
308
packages/web/src/styles/EditCookbook.css
Normal file
@@ -0,0 +1,308 @@
|
||||
/* Edit Cookbook Page */
|
||||
|
||||
.edit-cookbook-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
color: #2e7d32;
|
||||
margin: 1rem 0 0 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2e7d32;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: #1b5e20;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edit-cookbook-form {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #424242;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-radius: 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-chip button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2e7d32;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.filter-chip button:hover {
|
||||
background-color: rgba(46, 125, 50, 0.2);
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-add-filter {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-add-filter:hover {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
max-width: 300px;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.image-url {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.image-upload-section {
|
||||
background-color: #f9f9f9;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed #e0e0e0;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.file-input-label:hover {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.file-selected span {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: #424242;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
background-color: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-upload:hover:not(:disabled) {
|
||||
background-color: #1565c0;
|
||||
}
|
||||
|
||||
.btn-upload:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: #2e7d32;
|
||||
border: 2px solid #2e7d32;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #f1f8e9;
|
||||
}
|
||||
|
||||
/* Loading and Error States */
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.1rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.edit-cookbook-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.edit-cookbook-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -881,3 +881,94 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tags Input Styles */
|
||||
.tags-input-container {
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.tags-list .tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-radius: 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #2e7d32;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background-color: rgba(46, 125, 50, 0.2);
|
||||
}
|
||||
|
||||
.tag-input-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-input-row input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tag-input-row input:focus {
|
||||
outline: none;
|
||||
border-color: #2e7d32;
|
||||
}
|
||||
|
||||
.btn-add-tag {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: #2e7d32;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-add-tag:hover {
|
||||
background-color: #1b5e20;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
font-size: 0.85rem;
|
||||
color: #757575;
|
||||
margin-top: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user