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