mirror of
https://github.com/taogaetz/chefbible.git
synced 2025-12-06 11:47:24 -05:00
added magic link based auth, photo uploads with cloudinary, and recipe editing. Also we havea footer now.
This commit is contained in:
parent
f5c4a5e008
commit
0561d6cda5
186
README.md
186
README.md
@ -1,49 +1,161 @@
|
||||
# ChefBible
|
||||
# Chef Bible
|
||||
|
||||
## Overview
|
||||
**ChefBible** is a self-hosted, mobile-friendly recipe and menu management tool designed for **real-world kitchen use**.
|
||||
It is optimized for **speed**, **simplicity**, and so cooks can find, read, and add recipes in **under 10 seconds**.
|
||||
A self-hosted, mobile-friendly recipe and menu management tool designed for real-world kitchen use. Built with SvelteKit, TailwindCSS, and Prisma.
|
||||
|
||||
The app is built with:
|
||||
- **SvelteKit** for UI and server logic
|
||||
- **TailwindCSS** for responsive, kitchen-friendly design
|
||||
- **Prisma** as the ORM
|
||||
- **PostgreSQL** as the database
|
||||
## Features
|
||||
|
||||
This repo is intentionally structured to make onboarding **both humans and LLMs** easy, so all core context is documented here.
|
||||
- **Recipe Management**: Store recipes with ingredients, instructions, and photos
|
||||
- **Ingredient Tracking**: Manage ingredients with allergen information
|
||||
- **Menu Planning**: Create menus from recipes with seasonal organization
|
||||
- **Kitchen-Friendly UI**: Optimized for tablets and phones in kitchen environments
|
||||
- **Authentication**: Simple magic link authentication for chefs
|
||||
|
||||
---
|
||||
## Tech Stack
|
||||
|
||||
## Purpose
|
||||
ChefBible aims to replace clunky recipe binders, scattered Google Docs, and slow web apps with something **built for the kitchen**.
|
||||
It focuses on:
|
||||
1. Storing and organizing recipes
|
||||
2. Tracking ingredients and allergens
|
||||
3. Building menus from recipes
|
||||
4. Displaying everything in a **fast, easy-to-read format** on tablets or phones
|
||||
- **Frontend**: SvelteKit 2.0 with Svelte 5
|
||||
- **Styling**: TailwindCSS 4.0 + DaisyUI
|
||||
- **Database**: SQLite with Prisma ORM
|
||||
- **Authentication**: Magic link system
|
||||
- **Build Tool**: Vite
|
||||
|
||||
**MVP Rule:**
|
||||
If a feature isn’t needed for a cook to find, read, or add a recipe in **<10 seconds**, it is not part of Phase 1–2.
|
||||
## Prerequisites
|
||||
|
||||
---
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
## Core Features
|
||||
## Quick Start
|
||||
|
||||
### Recipes
|
||||
- Name, description, instructions, optional photo
|
||||
- Linked ingredients (with quantity/unit/prep notes)
|
||||
- Automatic allergen mapping from ingredients
|
||||
- “Kitchen Mode” for step-by-step cooking view
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd chefbible
|
||||
```
|
||||
|
||||
### Ingredients
|
||||
- Unique name
|
||||
- Linked allergens
|
||||
- Linked to recipes via `RecipeIngredient`
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Allergens
|
||||
- Name + optional description
|
||||
- Linked to ingredients
|
||||
3. **Set up environment variables**
|
||||
Create a `.env` file in the root directory:
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL="file:./prisma/database.db"
|
||||
|
||||
# Authentication (generate a secure random token for production)
|
||||
MAGIC_LINK_TOKEN="your-secure-token-here"
|
||||
|
||||
# Environment
|
||||
NODE_ENV="development"
|
||||
```
|
||||
|
||||
### Menus
|
||||
- Name + optional season
|
||||
- Contains recipes in sortable order
|
||||
4. **Set up the database**
|
||||
```bash
|
||||
# Generate Prisma client
|
||||
npx prisma generate
|
||||
|
||||
# Run database migrations
|
||||
npx prisma migrate dev
|
||||
|
||||
# Seed the database with sample data (optional)
|
||||
npm run prisma:seed
|
||||
```
|
||||
|
||||
5. **Start the development server**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. **Access the application**
|
||||
- Open [http://localhost:5173](http://localhost:5173)
|
||||
- Use the magic link: `/chef-access?token=your-secure-token-here`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | Database connection string | `file:./prisma/database.db` |
|
||||
| `MAGIC_LINK_TOKEN` | Secret token for chef authentication | Required |
|
||||
| `NODE_ENV` | Environment mode | `development` |
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses the following main models:
|
||||
|
||||
- **Recipe**: Core recipe information with instructions and metadata
|
||||
- **Ingredient**: Ingredients with allergen tracking
|
||||
- **RecipeIngredient**: Junction table linking recipes to ingredients with quantities
|
||||
- **Allergen**: Allergen information linked to ingredients
|
||||
- **Menu**: Menu collections with seasonal organization
|
||||
- **MenuRecipe**: Junction table for menu-recipe relationships
|
||||
|
||||
## Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
- `npm run check` - Type check and lint
|
||||
- `npm run format` - Format code with Prettier
|
||||
|
||||
### Database Operations
|
||||
|
||||
- `npx prisma studio` - Open Prisma Studio for database management
|
||||
- `npx prisma migrate dev` - Create and apply new migrations
|
||||
- `npx prisma generate` - Generate Prisma client
|
||||
- `npx prisma db seed` - Seed database with sample data
|
||||
|
||||
### Authentication
|
||||
|
||||
The app uses a simple magic link authentication system:
|
||||
- Generate a secure token and set it in `MAGIC_LINK_TOKEN`
|
||||
- Share the magic link: `/chef-access?token=your-token`
|
||||
- Authentication lasts for 30 days via HTTP-only cookies
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Build
|
||||
|
||||
1. Build the application:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. The built application will be in the `build/` directory
|
||||
|
||||
3. Deploy using your preferred hosting platform (Vercel, Netlify, etc.)
|
||||
|
||||
### Environment Considerations
|
||||
|
||||
- Set `NODE_ENV=production` in production
|
||||
- Use a strong, randomly generated `MAGIC_LINK_TOKEN`
|
||||
- Consider using a production database (PostgreSQL, MySQL) instead of SQLite
|
||||
- Update the Prisma schema datasource provider if switching databases
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── server/ # Server-side utilities
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── routes/ # SvelteKit route handlers
|
||||
│ ├── chef-access/ # Authentication endpoint
|
||||
│ ├── recipe/ # Recipe management routes
|
||||
│ └── logout/ # Logout functionality
|
||||
└── app.html # HTML template
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run tests and linting: `npm run check`
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
[Add your license here]
|
||||
|
||||
31
package-lock.json
generated
31
package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.14.0",
|
||||
"@prisma/extension-accelerate": "^2.0.2",
|
||||
"cloudinary": "^2.7.0",
|
||||
"convert": "^5.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -2140,6 +2141,19 @@
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/cloudinary": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz",
|
||||
"integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"q": "^1.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@ -3371,6 +3385,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@ -4024,6 +4044,17 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/q": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
|
||||
"integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
|
||||
"deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.0",
|
||||
"teleport": ">=0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@ -44,6 +44,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.14.0",
|
||||
"@prisma/extension-accelerate": "^2.0.2",
|
||||
"cloudinary": "^2.7.0",
|
||||
"convert": "^5.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
250
prisma/seed.ts
250
prisma/seed.ts
@ -1,5 +1,4 @@
|
||||
import { PrismaClient } from '@prisma-app/client';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -12,157 +11,130 @@ async function main() {
|
||||
await prisma.ingredient.deleteMany();
|
||||
await prisma.allergen.deleteMany();
|
||||
|
||||
console.log('🌱 Seeding database with food-themed data...');
|
||||
console.log('🌱 Seeding database with new recipes...');
|
||||
|
||||
// Allergens
|
||||
const allergenNames = ['Gluten', 'Dairy', 'Tree Nuts', 'Eggs', 'Fish', 'Soy'];
|
||||
const allergens = await Promise.all(
|
||||
allergenNames.map(name =>
|
||||
prisma.allergen.create({
|
||||
data: { name, description: faker.food.description() },
|
||||
})
|
||||
)
|
||||
);
|
||||
// Create ingredients first
|
||||
const ingredients = await Promise.all([
|
||||
prisma.ingredient.create({ data: { name: 'Vincotto' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Olive Oil' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Chives' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Shallots' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Salt & Pepper' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Chili Flakes' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Charred Orange' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Red Wine Vinegar' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Honey' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Dijon' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Garlic' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Orange Zest' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Water' } }),
|
||||
prisma.ingredient.create({ data: { name: 'White Wine Vinegar' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Sugar' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Salt' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Nduja' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Shallot Brunoise' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Butter' } }),
|
||||
prisma.ingredient.create({ data: { name: 'Sundried Cherry Tomato' } })
|
||||
]);
|
||||
|
||||
// Ingredients (mix of spices, vegetables, meats, fruits)
|
||||
const ingredientNames = new Set<string>();
|
||||
const ingredients: any[] = [];
|
||||
// Create a map for easy ingredient lookup
|
||||
const ingredientMap = new Map(ingredients.map(ing => [ing.name, ing]));
|
||||
|
||||
while (ingredientNames.size < 20) {
|
||||
const typePick = faker.helpers.arrayElement([
|
||||
() => faker.food.ingredient(),
|
||||
() => faker.food.spice(),
|
||||
() => faker.food.vegetable(),
|
||||
() => faker.food.meat(),
|
||||
() => faker.food.fruit(),
|
||||
]);
|
||||
|
||||
const name = typePick();
|
||||
if (!ingredientNames.has(name)) {
|
||||
ingredientNames.add(name);
|
||||
const randomAllergens = faker.helpers.arrayElements(allergens, faker.number.int({ min: 0, max: 2 }));
|
||||
const ingredient = await prisma.ingredient.create({
|
||||
data: {
|
||||
name,
|
||||
allergens: { connect: randomAllergens.map(a => ({ id: a.id })) },
|
||||
},
|
||||
});
|
||||
ingredients.push(ingredient);
|
||||
// Recipe 1: Vincotto Dressing
|
||||
const vincottoDressing = await prisma.recipe.create({
|
||||
data: {
|
||||
name: 'Vincotto Dressing',
|
||||
description: 'A rich and flavorful dressing made with vincotto, perfect for salads and marinades',
|
||||
time: 'Quick',
|
||||
station: 'Garde Manger',
|
||||
instructions: '1. Combine all ingredients in a bowl\n2. Whisk until well combined\n3. Season to taste with salt and pepper\n4. Store in refrigerator for up to 1 week',
|
||||
ingredients: {
|
||||
create: [
|
||||
{ ingredientId: ingredientMap.get('Vincotto')!.id, quantity: 90, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Olive Oil')!.id, quantity: 150, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Chives')!.id, quantity: 30, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Shallots')!.id, quantity: 45, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Salt & Pepper')!.id, quantity: null, unit: null, prep: 'Pinch to taste' },
|
||||
{ ingredientId: ingredientMap.get('Chili Flakes')!.id, quantity: null, unit: null, prep: 'Pinch to taste' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Recipes with realistic kitchen data
|
||||
const recipeData = [
|
||||
{
|
||||
name: 'Caesar Salad',
|
||||
description: 'Classic Caesar salad with homemade dressing and croutons',
|
||||
time: 'Quick',
|
||||
station: 'Garde Manger'
|
||||
},
|
||||
{
|
||||
name: 'Grilled Salmon',
|
||||
description: 'Fresh salmon fillet grilled to perfection with herbs',
|
||||
// Recipe 2: Charred Orange Salad Dressing
|
||||
const charredOrangeDressing = await prisma.recipe.create({
|
||||
data: {
|
||||
name: 'Charred Orange Salad Dressing',
|
||||
description: 'A bright and citrusy dressing with charred orange notes',
|
||||
time: 'Medium',
|
||||
station: 'Grill'
|
||||
},
|
||||
{
|
||||
name: 'Beef Stir Fry',
|
||||
description: 'Tender beef with vegetables in a savory sauce',
|
||||
time: 'Quick',
|
||||
station: 'Pans'
|
||||
},
|
||||
{
|
||||
name: 'Braised Short Ribs',
|
||||
description: 'Slow-cooked short ribs in red wine reduction',
|
||||
time: 'Long',
|
||||
station: 'Pans'
|
||||
},
|
||||
{
|
||||
name: 'Charcuterie Board',
|
||||
description: 'Artisanal meats and cheeses with accompaniments',
|
||||
time: 'Quick',
|
||||
station: 'Garde Manger'
|
||||
},
|
||||
{
|
||||
name: 'Grilled Chicken Breast',
|
||||
description: 'Herb-marinated chicken breast with seasonal vegetables',
|
||||
time: 'Medium',
|
||||
station: 'Grill'
|
||||
},
|
||||
{
|
||||
name: 'Pasta Carbonara',
|
||||
description: 'Classic Italian pasta with eggs, cheese, and pancetta',
|
||||
time: 'Medium',
|
||||
station: 'Pans'
|
||||
},
|
||||
{
|
||||
name: 'Beef Tenderloin',
|
||||
description: 'Premium cut grilled to your preference',
|
||||
time: 'Medium',
|
||||
station: 'Grill'
|
||||
},
|
||||
{
|
||||
name: 'Garden Salad',
|
||||
description: 'Fresh mixed greens with house vinaigrette',
|
||||
time: 'Quick',
|
||||
station: 'Garde Manger'
|
||||
},
|
||||
{
|
||||
name: 'Duck Confit',
|
||||
description: 'Traditional French duck confit with crispy skin',
|
||||
time: 'Long',
|
||||
station: 'Pans'
|
||||
station: 'Garde Manger',
|
||||
instructions: '1. Char oranges over open flame until blackened\n2. Juice the charred oranges\n3. Combine all ingredients in a bowl\n4. Whisk until emulsified\n5. Season to taste',
|
||||
ingredients: {
|
||||
create: [
|
||||
{ ingredientId: ingredientMap.get('Charred Orange')!.id, quantity: 5, unit: 'oranges' },
|
||||
{ ingredientId: ingredientMap.get('Olive Oil')!.id, quantity: 90, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Red Wine Vinegar')!.id, quantity: 30, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Honey')!.id, quantity: 20, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Dijon')!.id, quantity: 10, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Garlic')!.id, quantity: 2.5, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Orange Zest')!.id, quantity: null, unit: null, prep: 'Pinch to taste' },
|
||||
{ ingredientId: ingredientMap.get('Salt & Pepper')!.id, quantity: null, unit: null, prep: 'Pinch to taste' }
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
const recipes = await Promise.all(
|
||||
recipeData.map(async (recipeInfo) => {
|
||||
const recipeIngredients = faker.helpers.arrayElements(ingredients, faker.number.int({ min: 3, max: 6 }));
|
||||
return prisma.recipe.create({
|
||||
data: {
|
||||
name: recipeInfo.name,
|
||||
description: recipeInfo.description,
|
||||
time: recipeInfo.time,
|
||||
station: recipeInfo.station,
|
||||
instructions: faker.lorem.paragraph(),
|
||||
photoUrl: null,
|
||||
ingredients: {
|
||||
create: recipeIngredients.map(ing => ({
|
||||
ingredientId: ing.id,
|
||||
quantity: faker.number.int({ min: 10, max: 500 }),
|
||||
unit: faker.helpers.arrayElement(['g', 'ml', 'pcs']),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
// Recipe 3: Scallop Vinegar
|
||||
const scallopVinegar = await prisma.recipe.create({
|
||||
data: {
|
||||
name: 'Scallop Vinegar',
|
||||
description: 'A sweet and tangy vinegar solution for curing and marinating scallops',
|
||||
time: 'Quick',
|
||||
station: 'Garde Manger',
|
||||
instructions: '1. Combine all ingredients in a saucepan\n2. Bring to a boil, stirring to dissolve sugar and salt\n3. Remove from heat and let cool\n4. Store in refrigerator for up to 2 weeks',
|
||||
ingredients: {
|
||||
create: [
|
||||
{ ingredientId: ingredientMap.get('Water')!.id, quantity: 250, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('White Wine Vinegar')!.id, quantity: 250, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Sugar')!.id, quantity: 75, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Salt')!.id, quantity: 7.5, unit: 'ml' }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Menus
|
||||
const menus = await Promise.all(
|
||||
['Spring', 'Summer', 'Autumn', 'Winter'].map(season => {
|
||||
const seasonRecipes = faker.helpers.arrayElements(recipes, 3);
|
||||
return prisma.menu.create({
|
||||
data: {
|
||||
name: `${season} Menu: ${faker.food.ethnicCategory()}`,
|
||||
season,
|
||||
recipes: {
|
||||
create: seasonRecipes.map((rec, index) => ({
|
||||
recipeId: rec.id,
|
||||
position: index + 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
// Recipe 4: Nduja Spread
|
||||
const ndujaSpread = await prisma.recipe.create({
|
||||
data: {
|
||||
name: 'Nduja Spread',
|
||||
description: 'A spicy and flavorful spread made with nduja sausage',
|
||||
time: 'Medium',
|
||||
station: 'Garde Manger',
|
||||
instructions: '1. Soften butter to room temperature\n2. Combine all ingredients in a food processor\n3. Pulse until well combined but still chunky\n4. Season to taste\n5. Store in refrigerator for up to 1 week',
|
||||
ingredients: {
|
||||
create: [
|
||||
{ ingredientId: ingredientMap.get('Nduja')!.id, quantity: 450, unit: 'g' },
|
||||
{ ingredientId: ingredientMap.get('Red Wine Vinegar')!.id, quantity: 64.8, unit: 'ml' },
|
||||
{ ingredientId: ingredientMap.get('Shallot Brunoise')!.id, quantity: 108, unit: 'g' },
|
||||
{ ingredientId: ingredientMap.get('Sugar')!.id, quantity: 21.8, unit: 'g' },
|
||||
{ ingredientId: ingredientMap.get('Salt')!.id, quantity: 14.5, unit: 'g' },
|
||||
{ ingredientId: ingredientMap.get('Butter')!.id, quantity: 270, unit: 'g' },
|
||||
{ ingredientId: ingredientMap.get('Sundried Cherry Tomato')!.id, quantity: 162, unit: 'g' },
|
||||
{ ingredientId: ingredientMap.get('Chili Flakes')!.id, quantity: 10, unit: 'g' }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Seeding complete!');
|
||||
console.log('--- Summary ---');
|
||||
console.log(`Allergens created: ${allergens.length}`);
|
||||
console.log(`Ingredients created: ${ingredients.length}`);
|
||||
console.log(`Recipes created: ${recipes.length}`);
|
||||
console.log(`Menus created: ${menus.length}`);
|
||||
console.log(`Recipes created: 4`);
|
||||
console.log('Recipes:');
|
||||
console.log('- Vincotto Dressing');
|
||||
console.log('- Charred Orange Salad Dressing');
|
||||
console.log('- Scallop Vinegar');
|
||||
console.log('- Nduja Spread');
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
8
src/app.d.ts
vendored
8
src/app.d.ts
vendored
@ -1,13 +1,15 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
interface Locals {
|
||||
authenticated: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export { };
|
||||
|
||||
14
src/hooks.server.ts
Normal file
14
src/hooks.server.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Check if user is authenticated using the new cookie API
|
||||
const authenticated = event.cookies.get('chef_bible_auth') === 'authenticated';
|
||||
|
||||
// Add authentication status to locals for use in load functions
|
||||
event.locals.authenticated = authenticated;
|
||||
|
||||
// Continue with the request
|
||||
const response = await resolve(event);
|
||||
|
||||
return response;
|
||||
};
|
||||
14
src/lib/auth.ts
Normal file
14
src/lib/auth.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// Magic link path
|
||||
export const MAGIC_LINK_PATH = '/chef-access';
|
||||
|
||||
// Client-side function - generates magic link URL
|
||||
// Note: This function is used on the client side, so it can't access environment variables
|
||||
export function generateMagicLink(baseUrl: string, token: string): string {
|
||||
return `${baseUrl}${MAGIC_LINK_PATH}?token=${token}`;
|
||||
}
|
||||
|
||||
// Server-side function - validates magic link token
|
||||
// This function should be imported only in server-side code
|
||||
export function validateMagicLinkToken(token: string, expectedToken: string): boolean {
|
||||
return token === expectedToken;
|
||||
}
|
||||
26
src/lib/components/LogoutButton.svelte
Normal file
26
src/lib/components/LogoutButton.svelte
Normal file
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
function handleLogout() {
|
||||
// Submit a form to the logout endpoint to clear the HTTP-only cookie
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/logout';
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick={handleLogout}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16,17 21,12 16,7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
@ -3,15 +3,21 @@
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export let recipe: Recipe;
|
||||
export let authenticated: boolean = false;
|
||||
|
||||
function handleClick() {
|
||||
goto(`/recipe/${recipe.id}`);
|
||||
}
|
||||
|
||||
function handleEdit(e: Event) {
|
||||
e.stopPropagation();
|
||||
goto(`/recipe/${recipe.id}/edit`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="card flex h-full cursor-pointer flex-col border border-base-200 bg-white shadow-lg transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
||||
on:click={handleClick}
|
||||
onclick={handleClick}
|
||||
>
|
||||
<div class="relative h-48 w-full overflow-hidden">
|
||||
{#if recipe.photoUrl}
|
||||
@ -20,7 +26,7 @@
|
||||
alt={recipe.name}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
on:error={(e) => {
|
||||
onerror={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.nextElementSibling?.classList.remove('hidden');
|
||||
@ -101,9 +107,30 @@
|
||||
</svg>
|
||||
{recipe.time || 'Medium'}
|
||||
</span>
|
||||
<span class="badge badge-sm badge-success">
|
||||
{recipe.station || 'Pans'}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-sm badge-success">
|
||||
{recipe.station || 'Pans'}
|
||||
</span>
|
||||
{#if authenticated}
|
||||
<button
|
||||
class="btn btn-outline btn-xs btn-primary"
|
||||
onclick={handleEdit}
|
||||
title="Edit Recipe"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,23 @@ import type { LayoutServerLoad } from './$types';
|
||||
|
||||
import prisma from '$lib/server/prisma';
|
||||
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
const recipes = await prisma.recipe.findMany();
|
||||
return { recipes };
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
// Get all recipes for search functionality
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
include: {
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
recipes,
|
||||
authenticated: locals.authenticated
|
||||
};
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import logo from '$lib/assets/logo.webp';
|
||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||
import LogoutButton from '$lib/components/LogoutButton.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { children, data } = $props<{ children: any; data: LayoutData }>();
|
||||
@ -23,13 +24,49 @@
|
||||
</a>
|
||||
</div>
|
||||
<SearchBar recipes={data.recipes} />
|
||||
<a href="/recipe/new" class="btn font-extrabold btn-lg btn-primary">+</a>
|
||||
<a
|
||||
href={data.authenticated ? '/recipe/new' : '#'}
|
||||
class="btn font-extrabold btn-lg btn-primary {!data.authenticated
|
||||
? 'btn-disabled opacity-50'
|
||||
: ''}"
|
||||
onclick={!data.authenticated ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
+
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-base-300 bg-base-100/95 py-6">
|
||||
<div class="mx-auto max-w-[1400px] px-5">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<div class="text-sm text-base-content/60">
|
||||
© 2025 Birbante - Italian Apericena & Dolce Vita
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/chef-access" class="btn btn-ghost btn-sm">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M9 12l2 2 4-4M5 12h14" />
|
||||
</svg>
|
||||
Chef Access
|
||||
</a>
|
||||
{#if data.authenticated}
|
||||
<LogoutButton />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import RecipeCard from '$lib/components/RecipeCard.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
// Debug: Log the first recipe to see what fields are available
|
||||
console.log('First recipe data:', data.recipes[0]);
|
||||
|
||||
// Check if user just authenticated
|
||||
let justAuthenticated = $state($page.url.searchParams.get('auth') === 'success');
|
||||
|
||||
function closeAlert() {
|
||||
justAuthenticated = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@ -14,11 +22,51 @@
|
||||
</svelte:head>
|
||||
|
||||
<main class="m-0 min-h-screen bg-gradient-to-br from-base-200 to-base-300 p-0">
|
||||
<section class="mx-auto max-w-7xl p-10 lg:p-6">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each data.recipes as recipe}
|
||||
<RecipeCard {recipe} />
|
||||
{/each}
|
||||
{#if justAuthenticated}
|
||||
<div class="mx-auto max-w-7xl p-4">
|
||||
<div class="alert alert-success">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<span>
|
||||
<strong>Welcome!</strong> You now have access to create, edit, and delete recipes. This access
|
||||
will last for 30 days.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={closeAlert}
|
||||
aria-label="Close alert"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if data.authenticated}
|
||||
<section class="mx-auto max-w-7xl p-10 lg:p-6">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each data.recipes as recipe}
|
||||
<RecipeCard {recipe} authenticated={data.authenticated} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="mx-auto max-w-7xl p-10 lg:p-6">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each data.recipes as recipe}
|
||||
<RecipeCard {recipe} authenticated={data.authenticated} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
38
src/routes/chef-access/+page.server.ts
Normal file
38
src/routes/chef-access/+page.server.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { validateMagicLinkToken } from '$lib/auth';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { MAGIC_LINK_TOKEN } from '$env/static/private';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
// If no token provided, just show the page normally
|
||||
if (!token) {
|
||||
return {
|
||||
authenticated: false,
|
||||
message: 'No authentication token provided',
|
||||
magicLinkToken: MAGIC_LINK_TOKEN
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the token
|
||||
if (validateMagicLinkToken(token, MAGIC_LINK_TOKEN)) {
|
||||
// Set the authentication cookie using the new API
|
||||
cookies.set('chef_bible_auth', 'authenticated', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 30 * 24 * 60 * 60 // 30 days in seconds
|
||||
});
|
||||
|
||||
// Redirect to home page with success message
|
||||
throw redirect(302, '/?auth=success');
|
||||
} else {
|
||||
// Invalid token
|
||||
return {
|
||||
authenticated: false,
|
||||
message: 'Invalid authentication token',
|
||||
magicLinkToken: MAGIC_LINK_TOKEN
|
||||
};
|
||||
}
|
||||
};
|
||||
129
src/routes/chef-access/+page.svelte
Normal file
129
src/routes/chef-access/+page.svelte
Normal file
@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { generateMagicLink } from '$lib/auth';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// Generate the magic link for the current domain using the token from server
|
||||
const magicLink = generateMagicLink(window.location.origin, data.magicLinkToken);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Chef Access - Chef Bible</title>
|
||||
<meta name="description" content="Access to add and edit recipes for kitchen staff training" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="m-0 min-h-screen bg-gradient-to-br from-base-200 to-base-300 p-0">
|
||||
<div class="mx-auto max-w-4xl p-10 lg:p-6">
|
||||
<div class="card bg-white shadow-lg">
|
||||
<div class="card-body p-8">
|
||||
<h2 class="mb-6 card-title text-3xl font-bold text-base-content">Kitchen Staff Access</h2>
|
||||
|
||||
<div class="prose max-w-none">
|
||||
<p class="mb-6 text-lg text-base-content/70">
|
||||
Scan the QR code below to get access to create and edit recipes. This access will last
|
||||
for 30 days.
|
||||
</p>
|
||||
|
||||
<div class="my-8 flex justify-center">
|
||||
<div class="rounded-lg border-4 border-base-200 bg-white p-6">
|
||||
<!-- QR Code Placeholder - In production, you'd generate an actual QR code -->
|
||||
<div
|
||||
class="flex h-64 w-64 items-center justify-center rounded-lg bg-base-100 text-center"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-4 text-4xl">📱</div>
|
||||
<div class="text-sm text-base-content/60">QR Code</div>
|
||||
<div class="text-xs text-base-content/40">Scan with your phone</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<span>
|
||||
<strong>Magic Link:</strong>
|
||||
<a href={magicLink} class="link break-all link-primary">{magicLink}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-lg border border-base-200 bg-base-100 p-6">
|
||||
<h3 class="mb-4 text-xl font-bold">What You Can Do With Access:</h3>
|
||||
<ul class="space-y-2 text-base-content/70">
|
||||
<li class="flex items-center gap-2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="text-success"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Create new recipes with ingredients, instructions, and photos
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="text-success"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit existing recipes to update ingredients or instructions
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="text-success"
|
||||
>
|
||||
<path
|
||||
d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
Delete recipes when they're no longer needed
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="text-success"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
Manage recipe details like cooking time and station
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
28
src/routes/chef-cookie/+page.server.ts
Normal file
28
src/routes/chef-cookie/+page.server.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies, setHeaders }) => {
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
// Check if the token is valid (you can customize this validation)
|
||||
// For now, we'll accept any token that's at least 20 characters long
|
||||
if (!token || token.length < 20) {
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
// Set the chef cookie (valid for 30 days)
|
||||
cookies.set('chef_token', token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
||||
});
|
||||
|
||||
// Set a client-side cookie for UI state
|
||||
setHeaders({
|
||||
'set-cookie': `chef_authenticated=true; Path=/; Max-Age=${60 * 60 * 24 * 30}; SameSite=Lax`
|
||||
});
|
||||
|
||||
throw redirect(302, '/');
|
||||
};
|
||||
10
src/routes/chef-cookie/+page.svelte
Normal file
10
src/routes/chef-cookie/+page.svelte
Normal file
@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
// This page will redirect immediately via the server-side load function
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-lg loading-spinner text-primary"></span>
|
||||
<p class="mt-4 text-lg">Setting chef cookie...</p>
|
||||
</div>
|
||||
</div>
|
||||
28
src/routes/logout/+server.ts
Normal file
28
src/routes/logout/+server.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
// Clear the authentication cookie using the new API
|
||||
cookies.set('chef_bible_auth', '', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 0 // Expire immediately
|
||||
});
|
||||
|
||||
// Redirect to home page
|
||||
throw redirect(302, '/');
|
||||
};
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
// Clear the authentication cookie using the new API
|
||||
cookies.set('chef_bible_auth', '', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 0 // Expire immediately
|
||||
});
|
||||
|
||||
// Redirect to home page
|
||||
throw redirect(302, '/');
|
||||
};
|
||||
@ -81,6 +81,43 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if data.authenticated}
|
||||
<div class="mb-6 flex justify-end">
|
||||
<a href="/recipe/{recipe.id}/edit" class="btn gap-2 btn-primary">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit Recipe
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-6 flex justify-end">
|
||||
<button class="btn-disabled btn gap-2 opacity-50 btn-primary" disabled>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit Recipe
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-4 sm:flex-row">
|
||||
<div class="badge gap-2 badge-outline badge-lg">
|
||||
<svg
|
||||
@ -110,17 +147,19 @@
|
||||
</svg>
|
||||
<span class="font-semibold">{recipe.station}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="font-semibold text-base-content/70">Scale</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="font-semibold text-base-content/70">Scale</label>
|
||||
<span class="badge min-w-[3rem] text-center badge-primary">{scale}×</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.25"
|
||||
max="4"
|
||||
step="0.25"
|
||||
min="0.5"
|
||||
max="6"
|
||||
step="0.5"
|
||||
bind:value={scale}
|
||||
class="range range-primary"
|
||||
class="range w-full range-primary"
|
||||
/>
|
||||
<span class="badge">{scale}×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
30
src/routes/recipe/[id]/edit/+page.server.ts
Normal file
30
src/routes/recipe/[id]/edit/+page.server.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import prisma from '$lib/server/prisma';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
// Check if user is authenticated
|
||||
if (!locals.authenticated) {
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
const recipe = await prisma.recipe.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
throw new Error('Recipe not found');
|
||||
}
|
||||
|
||||
return {
|
||||
recipe,
|
||||
authenticated: locals.authenticated
|
||||
};
|
||||
};
|
||||
661
src/routes/recipe/[id]/edit/+page.svelte
Normal file
661
src/routes/recipe/[id]/edit/+page.svelte
Normal file
@ -0,0 +1,661 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let pending = $state(false);
|
||||
let lastError = $state<string | null>(null);
|
||||
let lastSuccess = $state<string | null>(null);
|
||||
let ingredientsText = $state('');
|
||||
let formSubmitted = $state(false);
|
||||
let showDeleteConfirm = $state(false);
|
||||
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Initialize form with existing data
|
||||
$effect(() => {
|
||||
if (data.recipe) {
|
||||
// Convert recipe ingredients back to text format
|
||||
const ingredientLines = data.recipe.ingredients.map((ri) => {
|
||||
let line = '';
|
||||
if (ri.quantity) line += ri.quantity;
|
||||
if (ri.unit) line += ' ' + ri.unit;
|
||||
if (ri.ingredient.name) line += ' ' + ri.ingredient.name;
|
||||
if (ri.prep) line += ', ' + ri.prep;
|
||||
return line;
|
||||
});
|
||||
ingredientsText = ingredientLines.join('\n');
|
||||
}
|
||||
});
|
||||
|
||||
// Photo handling functions
|
||||
function handlePhotoChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (file) {
|
||||
// Validate file size (10MB limit)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
lastError = 'Photo must be less than 10MB';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
lastError = 'Please select a valid image file';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const preview = URL.createObjectURL(file);
|
||||
selectedPhoto = {
|
||||
file,
|
||||
preview,
|
||||
name: file.name,
|
||||
size: file.size
|
||||
};
|
||||
lastError = null;
|
||||
}
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
if (selectedPhoto) {
|
||||
URL.revokeObjectURL(selectedPhoto.preview);
|
||||
selectedPhoto = null;
|
||||
}
|
||||
// Reset the file input
|
||||
const fileInput = document.querySelector('input[name="photo"]') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Parsing functions for live preview
|
||||
function parseMixedNumber(input: string): number | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
// e.g. 1 1/2, 3/4, 2.5
|
||||
const parts = trimmed.split(/\s+/);
|
||||
let total = 0;
|
||||
for (const part of parts) {
|
||||
if (/^\d+\/\d+$/.test(part)) {
|
||||
const [num, den] = part.split('/').map(Number);
|
||||
if (den === 0) return null;
|
||||
total += num / den;
|
||||
} else if (/^\d+(?:\.\d+)?$/.test(part)) {
|
||||
total += Number(part);
|
||||
} else {
|
||||
// unexpected token
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
const unitAliases: Record<string, { canonical: string; system: 'volume' | 'mass' | 'count' }> = {
|
||||
// volume
|
||||
tsp: { canonical: 'ml', system: 'volume' },
|
||||
'tsp.': { canonical: 'ml', system: 'volume' },
|
||||
teaspoon: { canonical: 'ml', system: 'volume' },
|
||||
teaspoons: { canonical: 'ml', system: 'volume' },
|
||||
tbsp: { canonical: 'ml', system: 'volume' },
|
||||
'tbsp.': { canonical: 'ml', system: 'volume' },
|
||||
tablespoon: { canonical: 'ml', system: 'volume' },
|
||||
tablespoons: { canonical: 'ml', system: 'volume' },
|
||||
cup: { canonical: 'ml', system: 'volume' },
|
||||
cups: { canonical: 'ml', system: 'volume' },
|
||||
ml: { canonical: 'ml', system: 'volume' },
|
||||
milliliter: { canonical: 'ml', system: 'volume' },
|
||||
milliliters: { canonical: 'ml', system: 'volume' },
|
||||
l: { canonical: 'ml', system: 'volume' },
|
||||
liter: { canonical: 'ml', system: 'volume' },
|
||||
liters: { canonical: 'ml', system: 'volume' },
|
||||
pt: { canonical: 'ml', system: 'volume' },
|
||||
qt: { canonical: 'ml', system: 'volume' },
|
||||
gal: { canonical: 'ml', system: 'volume' },
|
||||
floz: { canonical: 'ml', system: 'volume' },
|
||||
'fl-oz': { canonical: 'ml', system: 'volume' },
|
||||
'fl oz': { canonical: 'ml', system: 'volume' },
|
||||
// mass
|
||||
g: { canonical: 'g', system: 'mass' },
|
||||
gram: { canonical: 'g', system: 'mass' },
|
||||
grams: { canonical: 'g', system: 'mass' },
|
||||
kg: { canonical: 'g', system: 'mass' },
|
||||
kilogram: { canonical: 'g', system: 'mass' },
|
||||
kilograms: { canonical: 'g', system: 'mass' },
|
||||
oz: { canonical: 'g', system: 'mass' },
|
||||
ounce: { canonical: 'g', system: 'mass' },
|
||||
ounces: { canonical: 'g', system: 'mass' },
|
||||
lb: { canonical: 'g', system: 'mass' },
|
||||
lbs: { canonical: 'g', system: 'mass' },
|
||||
pound: { canonical: 'g', system: 'mass' },
|
||||
pounds: { canonical: 'g', system: 'mass' },
|
||||
// count / generic pieces stay as-is
|
||||
clove: { canonical: 'clove', system: 'count' },
|
||||
cloves: { canonical: 'clove', system: 'count' },
|
||||
piece: { canonical: 'pc', system: 'count' },
|
||||
pieces: { canonical: 'pc', system: 'count' },
|
||||
pc: { canonical: 'pc', system: 'count' }
|
||||
};
|
||||
|
||||
function parseIngredientLine(
|
||||
line: string
|
||||
): { name: string; quantity: number | null; unit: string | null; prep: string | null } | null {
|
||||
const raw = line.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
// Split the line into parts
|
||||
const parts = raw.split(/\s+/);
|
||||
|
||||
// If only one part, it's just a name
|
||||
if (parts.length === 1) {
|
||||
return { name: parts[0], quantity: null, unit: null, prep: null };
|
||||
}
|
||||
|
||||
// Check if first part is a quantity
|
||||
const firstPart = parts[0];
|
||||
const quantity = parseMixedNumber(firstPart);
|
||||
|
||||
if (quantity !== null) {
|
||||
// We have a quantity, check if second part is a unit
|
||||
if (parts.length >= 3) {
|
||||
const secondPart = parts[1];
|
||||
// Check if second part looks like a unit (short, alphanumeric, and in our known units)
|
||||
if (secondPart.length <= 4 && /^[a-zA-Z]+$/.test(secondPart)) {
|
||||
const unitLower = secondPart.toLowerCase();
|
||||
// Only treat as unit if it's in our known unit aliases
|
||||
if (unitAliases[unitLower]) {
|
||||
const unit = unitLower;
|
||||
const name = parts.slice(2).join(' ');
|
||||
// Check if there's prep info after a comma
|
||||
const commaIndex = name.indexOf(',');
|
||||
let finalName = name;
|
||||
let prep = null;
|
||||
if (commaIndex !== -1) {
|
||||
finalName = name.substring(0, commaIndex);
|
||||
prep = name.substring(commaIndex + 1).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
name: finalName.trim(),
|
||||
quantity,
|
||||
unit,
|
||||
prep
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We have a quantity but no clear unit, check if it's a no-space unit like "3g"
|
||||
if (firstPart.length > 1) {
|
||||
const match = firstPart.match(/^(\d+(?:[\s-]+\d+\/\d+|\/\d+|\.\d+)?)([a-zA-Z]+)$/);
|
||||
if (match) {
|
||||
const [, qtyStr, unit] = match;
|
||||
const qtyNum = parseMixedNumber(qtyStr);
|
||||
if (qtyNum !== null) {
|
||||
const unitLower = unit.toLowerCase();
|
||||
// Only treat as unit if it's in our known unit aliases
|
||||
if (unitAliases[unitLower]) {
|
||||
const name = parts.slice(1).join(' ');
|
||||
const commaIndex = name.indexOf(',');
|
||||
let finalName = name;
|
||||
let prep = null;
|
||||
if (commaIndex !== -1) {
|
||||
finalName = name.substring(0, commaIndex);
|
||||
prep = name.substring(commaIndex + 1).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
name: finalName.trim(),
|
||||
quantity: qtyNum,
|
||||
unit: unitLower,
|
||||
prep
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quantity but no unit found
|
||||
const name = parts.slice(1).join(' ');
|
||||
const commaIndex = name.indexOf(',');
|
||||
let finalName = name;
|
||||
let prep = null;
|
||||
if (commaIndex !== -1) {
|
||||
finalName = name.substring(0, commaIndex);
|
||||
prep = name.substring(commaIndex + 1).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
name: finalName.trim(),
|
||||
quantity,
|
||||
unit: null,
|
||||
prep
|
||||
};
|
||||
}
|
||||
|
||||
// No quantity found in first part, check if it's a no-space unit like "1g"
|
||||
if (firstPart.length > 1) {
|
||||
const match = firstPart.match(/^(\d+(?:[\s-]+\d+\/\d+|\/\d+|\.\d+)?)([a-zA-Z]+)$/);
|
||||
if (match) {
|
||||
const [, qtyStr, unit] = match;
|
||||
const qtyNum = parseMixedNumber(qtyStr);
|
||||
if (qtyNum !== null) {
|
||||
const unitLower = unit.toLowerCase();
|
||||
// Only treat as unit if it's in our known unit aliases
|
||||
if (unitAliases[unitLower]) {
|
||||
const name = parts.slice(1).join(' ');
|
||||
const commaIndex = name.indexOf(',');
|
||||
let finalName = name;
|
||||
let prep = null;
|
||||
if (commaIndex !== -1) {
|
||||
finalName = name.substring(0, commaIndex);
|
||||
prep = name.substring(commaIndex + 1).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
name: finalName.trim(),
|
||||
quantity: qtyNum,
|
||||
unit: unitLower,
|
||||
prep
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No quantity found, treat as name only
|
||||
const name = parts.join(' ');
|
||||
const commaIndex = name.indexOf(',');
|
||||
let finalName = name;
|
||||
let prep = null;
|
||||
if (commaIndex !== -1) {
|
||||
finalName = name.substring(0, commaIndex);
|
||||
prep = name.substring(commaIndex + 1).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
name: finalName.trim(),
|
||||
quantity: null,
|
||||
unit: null,
|
||||
prep
|
||||
};
|
||||
}
|
||||
|
||||
async function onsubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
if (!form) return;
|
||||
|
||||
pending = true;
|
||||
lastError = null;
|
||||
lastSuccess = null;
|
||||
|
||||
try {
|
||||
// Parse ingredients on client side
|
||||
const ingredientsRaw = ingredientsText.trim();
|
||||
const parsedIngredients: Array<{
|
||||
name: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
prep: string | null;
|
||||
}> = [];
|
||||
|
||||
if (ingredientsRaw) {
|
||||
const lines = ingredientsRaw.split('\n').filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
const parsed = parseIngredientLine(line.trim());
|
||||
if (parsed) {
|
||||
parsedIngredients.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create FormData with parsed ingredients
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Remove the raw ingredients textarea
|
||||
formData.delete('ingredients');
|
||||
|
||||
// Add parsed ingredients as JSON
|
||||
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
|
||||
|
||||
// Add photo if selected
|
||||
if (selectedPhoto) {
|
||||
formData.append('photo', selectedPhoto.file);
|
||||
}
|
||||
|
||||
// Submit to server
|
||||
const response = await fetch(`?/update`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Show success message
|
||||
lastSuccess = 'Recipe updated successfully!';
|
||||
formSubmitted = true;
|
||||
|
||||
// Redirect to the updated recipe after a short delay
|
||||
const result = await response.json();
|
||||
if (result.location) {
|
||||
setTimeout(() => {
|
||||
window.location.href = result.location;
|
||||
}, 1500);
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
lastError = errorData.message || 'Failed to update recipe';
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = 'An error occurred while updating the recipe';
|
||||
console.error('Error updating recipe:', error);
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
if (!data.recipe?.id) return;
|
||||
|
||||
pending = true;
|
||||
lastError = null;
|
||||
lastSuccess = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/recipe/${data.recipe.id}/edit`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
lastSuccess = 'Recipe deleted successfully!';
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
lastError = errorData.message || 'Failed to delete recipe';
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = 'An error occurred while deleting the recipe';
|
||||
console.error('Error deleting recipe:', error);
|
||||
} finally {
|
||||
pending = false;
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto my-4 w-full max-w-2xl px-3 sm:my-6 sm:px-6 lg:max-w-4xl">
|
||||
<h1 class="mb-4 text-xl font-extrabold sm:mb-5 sm:text-2xl">Edit Recipe: {data.recipe?.name}</h1>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
{onsubmit}
|
||||
class="card relative grid gap-4 border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
|
||||
>
|
||||
{#if pending}
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-base-100/80 backdrop-blur-sm"
|
||||
>
|
||||
<div class="text-center">
|
||||
<span class="loading loading-lg loading-spinner text-primary"></span>
|
||||
<p class="mt-2 text-base-content/70">Updating your recipe...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label class="form-control sm:col-span-2 lg:col-span-1">
|
||||
<span class="label"><span class="label-text font-bold">Name</span></span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
autocomplete="off"
|
||||
inputmode="text"
|
||||
class="input-bordered input w-full"
|
||||
value={data.recipe?.name || ''}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Time</span></span>
|
||||
<select name="time" class="select-bordered select w-full">
|
||||
<option value="Quick" selected={data.recipe?.time === 'Quick'}>Quick</option>
|
||||
<option value="Medium" selected={data.recipe?.time === 'Medium'}>Medium</option>
|
||||
<option value="Long" selected={data.recipe?.time === 'Long'}>Long</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Station</span></span>
|
||||
<select name="station" class="select-bordered select w-full">
|
||||
<option value="Garde Manger" selected={data.recipe?.station === 'Garde Manger'}
|
||||
>Garde Manger</option
|
||||
>
|
||||
<option value="Pans" selected={data.recipe?.station === 'Pans'}>Pans</option>
|
||||
<option value="Grill" selected={data.recipe?.station === 'Grill'}>Grill</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Photo (optional)</span></span>
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
class="file-input-bordered file-input w-full file-input-primary"
|
||||
onchange={handlePhotoChange}
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-sm text-base-content/60">
|
||||
Supports: JPG, PNG, GIF, WebP (max 10MB)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if selectedPhoto}
|
||||
<div class="flex items-center gap-4 rounded-lg bg-base-200 p-4">
|
||||
<div class="avatar">
|
||||
<div class="w-24 rounded-lg">
|
||||
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">{selectedPhoto.name}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
|
||||
Remove Photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.recipe?.photoUrl && !selectedPhoto}
|
||||
<div class="flex items-center gap-4 rounded-lg bg-base-200 p-4">
|
||||
<div class="avatar">
|
||||
<div class="w-24 rounded-lg">
|
||||
<img src={data.recipe.photoUrl} alt="Current recipe photo" class="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">Current Photo</p>
|
||||
<p class="text-xs text-base-content/60">Upload a new photo to replace this one</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span
|
||||
>
|
||||
|
||||
<textarea
|
||||
name="ingredients"
|
||||
rows="6"
|
||||
placeholder="2 cups flour, sifted
|
||||
3g salt
|
||||
500ml water"
|
||||
class="textarea-bordered textarea w-full"
|
||||
bind:value={ingredientsText}
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-sm text-base-content/60">
|
||||
<strong>Pattern:</strong> [quantity] [unit] ingredient name[, prep notes]<br />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ingredientsText}
|
||||
<div class="mt-3 rounded-lg bg-base-200 p-3">
|
||||
<div class="mb-2 text-sm font-semibold text-base-content/70">Live Preview:</div>
|
||||
{#each ingredientsText.split('\n').filter((line) => line.trim()) as line, i}
|
||||
{#if line.trim()}
|
||||
{@const parsed = parseIngredientLine(line.trim())}
|
||||
{#if parsed}
|
||||
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2">
|
||||
<div class="mb-1 text-xs text-base-content/50">Line {i + 1}:</div>
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
{#if parsed.quantity}
|
||||
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
|
||||
{/if}
|
||||
{#if parsed.unit}
|
||||
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
|
||||
{/if}
|
||||
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
|
||||
{#if parsed.prep}
|
||||
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="tooltip tooltip-top"
|
||||
data-tip="tsp, tbsp, cup(s), ml, l, g, kg, oz, lb, clove, piece, pc"
|
||||
>
|
||||
<strong class="cursor-help underline decoration-dotted">Valid units</strong>
|
||||
</div>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Description</span></span>
|
||||
<textarea name="description" rows="3" class="textarea-bordered textarea w-full"
|
||||
>{data.recipe?.description || ''}</textarea
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Instructions</span></span>
|
||||
<textarea name="instructions" rows="6" class="textarea-bordered textarea w-full"
|
||||
>{data.recipe?.instructions || ''}</textarea
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="mt-2 flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
{#if data.authenticated}
|
||||
<!-- Delete Recipe Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn order-1 w-full btn-outline btn-error sm:order-1 sm:w-auto"
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
Delete Recipe
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<a class="btn order-2 w-full sm:order-1 sm:w-auto" href="/recipe/{data.recipe?.id}">Cancel</a>
|
||||
<button
|
||||
class="btn order-1 w-full font-extrabold btn-primary sm:order-2 sm:w-auto"
|
||||
type="submit"
|
||||
name="/update"
|
||||
disabled={pending}
|
||||
>
|
||||
{#if pending}
|
||||
<span class="loading loading-sm loading-spinner"></span>
|
||||
Updating Recipe…
|
||||
{:else}
|
||||
Update Recipe
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if lastError}
|
||||
<p class="text-error">{lastError}</p>
|
||||
{/if}
|
||||
{#if lastSuccess}
|
||||
<div class="mt-3 alert alert-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{lastSuccess}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if showDeleteConfirm}
|
||||
<div class="modal-open modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold text-error">⚠️ Delete Recipe</h3>
|
||||
<p class="py-4">
|
||||
Are you sure you want to delete <strong>"{data.recipe?.name}"</strong>?
|
||||
<br /><br />
|
||||
<span class="font-semibold text-error">This action is irreversible!</span>
|
||||
<br /><br />
|
||||
The recipe and all its data will be permanently removed.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={() => (showDeleteConfirm = false)}> Cancel </button>
|
||||
<button class="btn btn-error" onclick={deleteRecipe}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
Delete Forever
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Utilities provided by Tailwind/daisyUI; no component-scoped CSS needed */
|
||||
</style>
|
||||
184
src/routes/recipe/[id]/edit/+server.ts
Normal file
184
src/routes/recipe/[id]/edit/+server.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import prisma from '$lib/server/prisma';
|
||||
import { v2 as cloudinary } from 'cloudinary';
|
||||
import { CLOUDINARY_URL } from '$env/static/private';
|
||||
|
||||
// Configure Cloudinary using the URL
|
||||
if (!CLOUDINARY_URL) {
|
||||
throw new Error('Missing CLOUDINARY_URL environment variable');
|
||||
}
|
||||
|
||||
cloudinary.config({
|
||||
url: CLOUDINARY_URL
|
||||
});
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params }) => {
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = (formData.get('name') as string | null)?.trim() ?? '';
|
||||
const description = (formData.get('description') as string | null)?.trim() || null;
|
||||
const instructions = (formData.get('instructions') as string | null)?.trim() || null;
|
||||
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
|
||||
const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
|
||||
const photo = formData.get('photo') as File | null;
|
||||
const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
|
||||
let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
|
||||
|
||||
if (!name) {
|
||||
return new Response(JSON.stringify({ type: 'error', message: 'Name is required' }), { status: 400 });
|
||||
}
|
||||
|
||||
if (parsedIngredientsRaw) {
|
||||
try {
|
||||
parsedIngredients = JSON.parse(parsedIngredientsRaw);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse ingredients JSON:', error);
|
||||
return new Response(JSON.stringify({ type: 'error', message: 'Invalid ingredients format' }), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
let photoUrl: string | null = null;
|
||||
|
||||
// Handle photo upload if provided
|
||||
if (photo && photo.size > 0) {
|
||||
try {
|
||||
// Convert File to buffer
|
||||
const arrayBuffer = await photo.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Upload to Cloudinary
|
||||
const result = await new Promise<{ secure_url: string }>((resolve, reject) => {
|
||||
cloudinary.uploader.upload_stream(
|
||||
{
|
||||
folder: 'chef-bible/recipes',
|
||||
resource_type: 'auto',
|
||||
transformation: [
|
||||
{ width: 800, height: 600, crop: 'limit' },
|
||||
{ quality: 'auto:good' }
|
||||
]
|
||||
},
|
||||
(error, result) => {
|
||||
if (error) reject(error);
|
||||
else if (result) resolve(result as { secure_url: string });
|
||||
else reject(new Error('No result from Cloudinary'));
|
||||
}
|
||||
).end(buffer);
|
||||
});
|
||||
|
||||
photoUrl = result.secure_url;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload photo to Cloudinary:', error);
|
||||
return new Response(JSON.stringify({ type: 'error', message: 'Failed to upload photo' }), { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing recipe to check if we need to preserve the existing photo
|
||||
const existingRecipe = await prisma.recipe.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!existingRecipe) {
|
||||
return new Response(JSON.stringify({ type: 'error', message: 'Recipe not found' }), { status: 404 });
|
||||
}
|
||||
|
||||
// Update recipe
|
||||
const recipe = await prisma.recipe.update({
|
||||
where: { id: params.id },
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
photoUrl: photoUrl || existingRecipe.photoUrl, // Keep existing photo if no new one uploaded
|
||||
time,
|
||||
station
|
||||
}
|
||||
});
|
||||
|
||||
// Delete existing recipe ingredients
|
||||
await prisma.recipeIngredient.deleteMany({
|
||||
where: { recipeId: params.id }
|
||||
});
|
||||
|
||||
if (parsedIngredients.length > 0) {
|
||||
// Upsert ingredients first
|
||||
const ingredientNames = parsedIngredients.map(i => i.name).filter(Boolean);
|
||||
const existingIngredients = await prisma.ingredient.findMany({
|
||||
where: { name: { in: ingredientNames } }
|
||||
});
|
||||
|
||||
const existingNames = new Set(existingIngredients.map(i => i.name));
|
||||
|
||||
const createIngredients = ingredientNames
|
||||
.filter(n => !existingNames.has(n))
|
||||
.map(n => ({ name: n }));
|
||||
|
||||
if (createIngredients.length) {
|
||||
await prisma.ingredient.createMany({
|
||||
data: createIngredients
|
||||
});
|
||||
}
|
||||
|
||||
// Get all ingredients with ids
|
||||
const allIngredients = await prisma.ingredient.findMany({
|
||||
where: { name: { in: ingredientNames } }
|
||||
});
|
||||
const ingredientMap = new Map(allIngredients.map(i => [i.name, i.id]));
|
||||
|
||||
// Bulk create recipeIngredient
|
||||
const recipeIngredientsData = parsedIngredients
|
||||
.filter(i => i.name)
|
||||
.map(i => ({
|
||||
recipeId: recipe.id,
|
||||
ingredientId: ingredientMap.get(i.name)!,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
prep: i.prep
|
||||
}));
|
||||
|
||||
if (recipeIngredientsData.length) {
|
||||
await prisma.recipeIngredient.createMany({
|
||||
data: recipeIngredientsData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
type: 'success',
|
||||
status: 200,
|
||||
location: `/recipe/${recipe.id}`
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
// Check if recipe exists
|
||||
const existingRecipe = await prisma.recipe.findUnique({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
if (!existingRecipe) {
|
||||
return new Response(JSON.stringify({ type: 'error', message: 'Recipe not found' }), { status: 404 });
|
||||
}
|
||||
|
||||
// Delete recipe ingredients first (due to foreign key constraints)
|
||||
await prisma.recipeIngredient.deleteMany({
|
||||
where: { recipeId: params.id }
|
||||
});
|
||||
|
||||
// Delete the recipe
|
||||
await prisma.recipe.delete({
|
||||
where: { id: params.id }
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
type: 'success',
|
||||
message: 'Recipe deleted successfully'
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (error) {
|
||||
console.error('Error deleting recipe:', error);
|
||||
return new Response(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'Failed to delete recipe'
|
||||
}), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
};
|
||||
@ -1,65 +1,15 @@
|
||||
// import type { Actions } from './$types';
|
||||
// import { fail } from '@sveltejs/kit';
|
||||
// import prisma from '$lib/server/prisma';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
// export const actions: Actions = {
|
||||
// create: async ({ request }) => {
|
||||
// const formData = await request.formData();
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Check if user is authenticated
|
||||
if (!locals.authenticated) {
|
||||
throw redirect(302, '/');
|
||||
}
|
||||
|
||||
// const name = (formData.get('name') as string | null)?.trim() ?? '';
|
||||
// const description = (formData.get('description') as string | null)?.trim() || null;
|
||||
// const instructions = (formData.get('instructions') as string | null)?.trim() || null;
|
||||
// const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
|
||||
// const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
|
||||
// const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
|
||||
// let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
|
||||
|
||||
// if (!name) {
|
||||
// return fail(400, { message: 'Name is required', name, description, instructions, time, station });
|
||||
// }
|
||||
|
||||
// // Parse the ingredients JSON from client
|
||||
// if (parsedIngredientsRaw) {
|
||||
// try {
|
||||
// parsedIngredients = JSON.parse(parsedIngredientsRaw);
|
||||
// } catch (error) {
|
||||
// console.error('Failed to parse ingredients JSON:', error);
|
||||
// return fail(400, { message: 'Invalid ingredients format', name, description, instructions, time, station });
|
||||
// }
|
||||
// }
|
||||
|
||||
// const recipe = await prisma.recipe.create({
|
||||
// data: {
|
||||
// name,
|
||||
// description,
|
||||
// instructions,
|
||||
// time,
|
||||
// station
|
||||
// }
|
||||
// });
|
||||
|
||||
// if (parsedIngredients.length > 0) {
|
||||
// for (const parsed of parsedIngredients) {
|
||||
// if (!parsed.name) continue;
|
||||
// const ingredient = await prisma.ingredient.upsert({
|
||||
// where: { name: parsed.name },
|
||||
// update: {},
|
||||
// create: { name: parsed.name }
|
||||
// });
|
||||
// await prisma.recipeIngredient.create({
|
||||
// data: {
|
||||
// recipeId: recipe.id,
|
||||
// ingredientId: ingredient.id,
|
||||
// quantity: parsed.quantity,
|
||||
// unit: parsed.unit,
|
||||
// prep: parsed.prep
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
// return { location: `/recipe/${recipe.id}` };
|
||||
// }
|
||||
// };
|
||||
return {
|
||||
authenticated: locals.authenticated
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let pending = $state(false);
|
||||
let lastError = $state<string | null>(null);
|
||||
let lastSuccess = $state<string | null>(null);
|
||||
let ingredientsText = $state('');
|
||||
let formSubmitted = $state(false);
|
||||
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Parsing functions for live preview
|
||||
function parseMixedNumber(input: string): number | null {
|
||||
@ -227,6 +234,12 @@
|
||||
lastError = null;
|
||||
lastSuccess = null;
|
||||
formSubmitted = false;
|
||||
|
||||
// Clear photo
|
||||
if (selectedPhoto) {
|
||||
URL.revokeObjectURL(selectedPhoto.preview);
|
||||
selectedPhoto = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onsubmit(event: SubmitEvent) {
|
||||
@ -267,6 +280,11 @@
|
||||
// Add parsed ingredients as JSON
|
||||
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
|
||||
|
||||
// Add photo if selected
|
||||
if (selectedPhoto) {
|
||||
formData.append('photo', selectedPhoto.file);
|
||||
}
|
||||
|
||||
// Submit to server
|
||||
const response = await fetch('?/create', {
|
||||
method: 'POST',
|
||||
@ -298,160 +316,271 @@
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Photo handling functions
|
||||
function handlePhotoChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (file) {
|
||||
// Validate file size (10MB limit)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
lastError = 'Photo must be less than 10MB';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
lastError = 'Please select a valid image file';
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview URL
|
||||
const preview = URL.createObjectURL(file);
|
||||
selectedPhoto = {
|
||||
file,
|
||||
preview,
|
||||
name: file.name,
|
||||
size: file.size
|
||||
};
|
||||
lastError = null;
|
||||
}
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
if (selectedPhoto) {
|
||||
URL.revokeObjectURL(selectedPhoto.preview);
|
||||
selectedPhoto = null;
|
||||
}
|
||||
// Reset the file input
|
||||
const fileInput = document.querySelector('input[name="photo"]') as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto my-4 w-full max-w-2xl px-3 sm:my-6 sm:px-6 lg:max-w-4xl">
|
||||
<h1 class="mb-4 text-xl font-extrabold sm:mb-5 sm:text-2xl">New Recipe</h1>
|
||||
{#if data.authenticated}
|
||||
<div class="mx-auto my-4 w-full max-w-2xl px-3 sm:my-6 sm:px-6 lg:max-w-4xl">
|
||||
<h1 class="mb-4 text-xl font-extrabold sm:mb-5 sm:text-2xl">Create New Recipe</h1>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
{onsubmit}
|
||||
class="card relative grid gap-4 border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
|
||||
>
|
||||
{#if pending}
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-base-100/80 backdrop-blur-sm"
|
||||
>
|
||||
<div class="text-center">
|
||||
<span class="loading loading-lg loading-spinner text-primary"></span>
|
||||
<p class="mt-2 text-base-content/70">Creating your recipe...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label class="form-control sm:col-span-2 lg:col-span-1">
|
||||
<span class="label"><span class="label-text font-bold">Name</span></span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
autocomplete="off"
|
||||
inputmode="text"
|
||||
class="input-bordered input w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Time</span></span>
|
||||
<select name="time" class="select-bordered select w-full">
|
||||
<option>Quick</option>
|
||||
<option selected>Medium</option>
|
||||
<option>Long</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Station</span></span>
|
||||
<select name="station" class="select-bordered select w-full">
|
||||
<option>Garde Manger</option>
|
||||
<option selected>Pans</option>
|
||||
<option>Grill</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span
|
||||
>
|
||||
|
||||
<textarea
|
||||
name="ingredients"
|
||||
rows="6"
|
||||
placeholder="2 cups flour, sifted
|
||||
3g salt
|
||||
500ml water"
|
||||
class="textarea-bordered textarea w-full"
|
||||
bind:value={ingredientsText}
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-sm text-base-content/60">
|
||||
<strong>Pattern:</strong> [quantity] [unit] ingredient name[, prep notes]<br />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ingredientsText}
|
||||
<div class="mt-3 rounded-lg bg-base-200 p-3">
|
||||
<div class="mb-2 text-sm font-semibold text-base-content/70">Live Preview:</div>
|
||||
{#each ingredientsText.split('\n').filter((line) => line.trim()) as line, i}
|
||||
{#if line.trim()}
|
||||
{@const parsed = parseIngredientLine(line.trim())}
|
||||
{#if parsed}
|
||||
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2">
|
||||
<div class="mb-1 text-xs text-base-content/50">Line {i + 1}:</div>
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
{#if parsed.quantity}
|
||||
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
|
||||
{/if}
|
||||
{#if parsed.unit}
|
||||
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
|
||||
{/if}
|
||||
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
|
||||
{#if parsed.prep}
|
||||
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
{onsubmit}
|
||||
class="card relative grid gap-4 border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
|
||||
>
|
||||
{#if pending}
|
||||
<div
|
||||
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-base-100/80 backdrop-blur-sm"
|
||||
>
|
||||
<div class="text-center">
|
||||
<span class="loading loading-lg loading-spinner text-primary"></span>
|
||||
<p class="mt-2 text-base-content/70">Creating your recipe...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</label>
|
||||
<div
|
||||
class="tooltip tooltip-top"
|
||||
data-tip="tsp, tbsp, cup(s), ml, l, g, kg, oz, lb, clove, piece, pc"
|
||||
>
|
||||
<strong class="cursor-help underline decoration-dotted">Valid units</strong>
|
||||
</div>
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Description</span></span>
|
||||
<textarea name="description" rows="3" class="textarea-bordered textarea w-full"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Instructions</span></span>
|
||||
<textarea name="instructions" rows="6" class="textarea-bordered textarea w-full"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="mt-2 flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<a class="btn order-2 w-full sm:order-1 sm:w-auto" href="/">Cancel</a>
|
||||
<button
|
||||
class="btn order-1 w-full font-extrabold btn-primary sm:order-2 sm:w-auto"
|
||||
type="submit"
|
||||
name="/create"
|
||||
disabled={pending}
|
||||
>
|
||||
{#if pending}
|
||||
<span class="loading loading-sm loading-spinner"></span>
|
||||
Creating Recipe…
|
||||
{:else}
|
||||
Create Recipe
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if lastError}
|
||||
<p class="text-error">{lastError}</p>
|
||||
{/if}
|
||||
{#if lastSuccess}
|
||||
<div class="mt-3 alert alert-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label class="form-control sm:col-span-2 lg:col-span-1">
|
||||
<span class="label"><span class="label-text font-bold">Name</span></span>
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
autocomplete="off"
|
||||
inputmode="text"
|
||||
class="input-bordered input w-full"
|
||||
/>
|
||||
</svg>
|
||||
<span>{lastSuccess}</span>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Time</span></span>
|
||||
<select name="time" class="select-bordered select w-full">
|
||||
<option>Quick</option>
|
||||
<option selected>Medium</option>
|
||||
<option>Long</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Station</span></span>
|
||||
<select name="station" class="select-bordered select w-full">
|
||||
<option>Garde Manger</option>
|
||||
<option selected>Pans</option>
|
||||
<option>Grill</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Photo (optional)</span></span>
|
||||
<input
|
||||
type="file"
|
||||
name="photo"
|
||||
accept="image/*"
|
||||
class="file-input-bordered file-input w-full file-input-primary"
|
||||
onchange={handlePhotoChange}
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-sm text-base-content/60">
|
||||
Supports: JPG, PNG, GIF, WebP (max 10MB)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if selectedPhoto}
|
||||
<div class="flex items-center gap-4 rounded-lg bg-base-200 p-4">
|
||||
<div class="avatar">
|
||||
<div class="w-24 rounded-lg">
|
||||
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium">{selectedPhoto.name}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
|
||||
Remove Photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"
|
||||
><span class="label-text font-bold">Ingredients (one per line)</span></span
|
||||
>
|
||||
|
||||
<textarea
|
||||
name="ingredients"
|
||||
rows="6"
|
||||
placeholder="2 cups flour, sifted
|
||||
3g salt
|
||||
500ml water"
|
||||
class="textarea-bordered textarea w-full"
|
||||
bind:value={ingredientsText}
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-sm text-base-content/60">
|
||||
<strong>Pattern:</strong> [quantity] [unit] ingredient name[, prep notes]<br />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if ingredientsText}
|
||||
<div class="mt-3 rounded-lg bg-base-200 p-3">
|
||||
<div class="mb-2 text-sm font-semibold text-base-content/70">Live Preview:</div>
|
||||
{#each ingredientsText.split('\n').filter((line) => line.trim()) as line, i}
|
||||
{#if line.trim()}
|
||||
{@const parsed = parseIngredientLine(line.trim())}
|
||||
{#if parsed}
|
||||
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2">
|
||||
<div class="mb-1 text-xs text-base-content/50">Line {i + 1}:</div>
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
{#if parsed.quantity}
|
||||
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
|
||||
{/if}
|
||||
{#if parsed.unit}
|
||||
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
|
||||
{/if}
|
||||
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
|
||||
{#if parsed.prep}
|
||||
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</label>
|
||||
<div
|
||||
class="tooltip tooltip-top"
|
||||
data-tip="tsp, tbsp, cup(s), ml, l, g, kg, oz, lb, clove, piece, pc"
|
||||
>
|
||||
<strong class="cursor-help underline decoration-dotted">Valid units</strong>
|
||||
</div>
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Description</span></span>
|
||||
<textarea name="description" rows="3" class="textarea-bordered textarea w-full"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label"><span class="label-text font-bold">Instructions</span></span>
|
||||
<textarea name="instructions" rows="6" class="textarea-bordered textarea w-full"></textarea>
|
||||
</label>
|
||||
|
||||
<div class="mt-2 flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<a class="btn order-2 w-full sm:order-1 sm:w-auto" href="/">Cancel</a>
|
||||
<button
|
||||
class="btn order-1 w-full font-extrabold btn-primary sm:order-2 sm:w-auto"
|
||||
type="submit"
|
||||
name="/create"
|
||||
disabled={pending}
|
||||
>
|
||||
{#if pending}
|
||||
<span class="loading loading-sm loading-spinner"></span>
|
||||
Creating Recipe…
|
||||
{:else}
|
||||
Create Recipe
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if lastError}
|
||||
<p class="text-error">{lastError}</p>
|
||||
{/if}
|
||||
{#if lastSuccess}
|
||||
<div class="mt-3 alert alert-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{lastSuccess}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto my-4 w-full max-w-2xl px-3 sm:my-6 sm:px-6 lg:max-w-4xl">
|
||||
<div class="card bg-white shadow-lg">
|
||||
<div class="card-body p-8 text-center">
|
||||
<h1 class="mb-6 card-title text-3xl font-bold text-base-content">Access Required</h1>
|
||||
<p class="mb-6 text-lg text-base-content/70">
|
||||
You need to authenticate to create new recipes.
|
||||
</p>
|
||||
<div class="mx-auto alert max-w-md alert-info">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<span>
|
||||
<strong>Kitchen Staff:</strong> Scan the QR code at
|
||||
<a href="/chef-access" class="link link-primary">Chef Access</a> to get access.
|
||||
</span>
|
||||
</div>
|
||||
<a href="/" class="btn mt-6 btn-primary">Back to Recipes</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Utilities provided by Tailwind/daisyUI; no component-scoped CSS needed */
|
||||
|
||||
@ -1,5 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import prisma from '$lib/server/prisma';
|
||||
import { v2 as cloudinary } from 'cloudinary';
|
||||
import { CLOUDINARY_URL } from '$env/static/private';
|
||||
|
||||
// Configure Cloudinary using the URL
|
||||
if (!CLOUDINARY_URL) {
|
||||
throw new Error('Missing CLOUDINARY_URL environment variable');
|
||||
}
|
||||
|
||||
cloudinary.config({
|
||||
url: CLOUDINARY_URL
|
||||
});
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
@ -9,6 +20,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
const instructions = (formData.get('instructions') as string | null)?.trim() || null;
|
||||
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string;
|
||||
const station = ((formData.get('station') as string | null)?.trim() || 'Pans') as string;
|
||||
const photo = formData.get('photo') as File | null;
|
||||
const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
|
||||
let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: string | null }> = [];
|
||||
|
||||
@ -25,12 +37,48 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
}
|
||||
}
|
||||
|
||||
let photoUrl: string | null = null;
|
||||
|
||||
// Handle photo upload if provided
|
||||
if (photo && photo.size > 0) {
|
||||
try {
|
||||
// Convert File to buffer
|
||||
const arrayBuffer = await photo.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Upload to Cloudinary
|
||||
const result = await new Promise<{ secure_url: string }>((resolve, reject) => {
|
||||
cloudinary.uploader.upload_stream(
|
||||
{
|
||||
folder: 'chef-bible/recipes',
|
||||
resource_type: 'auto',
|
||||
transformation: [
|
||||
{ width: 800, height: 600, crop: 'limit' },
|
||||
{ quality: 'auto:good' }
|
||||
]
|
||||
},
|
||||
(error, result) => {
|
||||
if (error) reject(error);
|
||||
else if (result) resolve(result as { secure_url: string });
|
||||
else reject(new Error('No result from Cloudinary'));
|
||||
}
|
||||
).end(buffer);
|
||||
});
|
||||
|
||||
photoUrl = result.secure_url;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload photo to Cloudinary:', error);
|
||||
return new Response(JSON.stringify({ type: 'error', message: 'Failed to upload photo' }), { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// Create recipe
|
||||
const recipe = await prisma.recipe.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
photoUrl,
|
||||
time,
|
||||
station
|
||||
}
|
||||
|
||||
@ -4,4 +4,6 @@ import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
// Ensure environment variables are loaded
|
||||
envPrefix: 'VITE_'
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user