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

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:
2025-11-02 05:19:34 +00:00
parent d6fceccba5
commit 6d6abd7729
26 changed files with 4380 additions and 146 deletions

View File

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

View File

@@ -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) => {

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

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

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

View File

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

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

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

View File

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

View File

@@ -40,7 +40,7 @@ describe('Shared Types', () => {
totalTime: 45,
servings: 4,
cuisine: 'Italian',
category: 'Main Course',
categories: ['Main Course', 'Pasta'],
rating: 4.5,
};

View File

@@ -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[];
}

View File

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

View File

@@ -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>

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

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

View File

@@ -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">

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

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

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

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

View File

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

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

View File

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