added magic link based auth, photo uploads with cloudinary, and recipe editing. Also we havea footer now.

This commit is contained in:
taogaetz 2025-09-03 12:20:32 -04:00
parent f5c4a5e008
commit 0561d6cda5
25 changed files with 1985 additions and 409 deletions

184
README.md
View File

@ -1,49 +1,161 @@
# Chef Bible # Chef Bible
## Overview A self-hosted, mobile-friendly recipe and menu management tool designed for real-world kitchen use. Built with SvelteKit, TailwindCSS, and Prisma.
**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**.
The app is built with: ## Features
- **SvelteKit** for UI and server logic
- **TailwindCSS** for responsive, kitchen-friendly design
- **Prisma** as the ORM
- **PostgreSQL** as the database
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 - **Frontend**: SvelteKit 2.0 with Svelte 5
ChefBible aims to replace clunky recipe binders, scattered Google Docs, and slow web apps with something **built for the kitchen**. - **Styling**: TailwindCSS 4.0 + DaisyUI
It focuses on: - **Database**: SQLite with Prisma ORM
1. Storing and organizing recipes - **Authentication**: Magic link system
2. Tracking ingredients and allergens - **Build Tool**: Vite
3. Building menus from recipes
4. Displaying everything in a **fast, easy-to-read format** on tablets or phones
**MVP Rule:** ## Prerequisites
If a feature isnt needed for a cook to find, read, or add a recipe in **<10 seconds**, it is not part of Phase 12.
--- - Node.js 18+
- npm or yarn
## Core Features ## Quick Start
### Recipes 1. **Clone the repository**
- Name, description, instructions, optional photo ```bash
- Linked ingredients (with quantity/unit/prep notes) git clone <repository-url>
- Automatic allergen mapping from ingredients cd chefbible
- “Kitchen Mode” for step-by-step cooking view ```
### Ingredients 2. **Install dependencies**
- Unique name ```bash
- Linked allergens npm install
- Linked to recipes via `RecipeIngredient` ```
### Allergens 3. **Set up environment variables**
- Name + optional description Create a `.env` file in the root directory:
- Linked to ingredients ```env
# Database
DATABASE_URL="file:./prisma/database.db"
### Menus # Authentication (generate a secure random token for production)
- Name + optional season MAGIC_LINK_TOKEN="your-secure-token-here"
- Contains recipes in sortable order
# Environment
NODE_ENV="development"
```
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
View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.14.0", "@prisma/client": "^6.14.0",
"@prisma/extension-accelerate": "^2.0.2", "@prisma/extension-accelerate": "^2.0.2",
"cloudinary": "^2.7.0",
"convert": "^5.12.0" "convert": "^5.12.0"
}, },
"devDependencies": { "devDependencies": {
@ -2140,6 +2141,19 @@
"consola": "^3.2.3" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -3371,6 +3385,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4024,6 +4044,17 @@
], ],
"license": "MIT" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -44,6 +44,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.14.0", "@prisma/client": "^6.14.0",
"@prisma/extension-accelerate": "^2.0.2", "@prisma/extension-accelerate": "^2.0.2",
"cloudinary": "^2.7.0",
"convert": "^5.12.0" "convert": "^5.12.0"
} }
} }

View File

@ -1,5 +1,4 @@
import { PrismaClient } from '@prisma-app/client'; import { PrismaClient } from '@prisma-app/client';
import { faker } from '@faker-js/faker';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -12,157 +11,130 @@ async function main() {
await prisma.ingredient.deleteMany(); await prisma.ingredient.deleteMany();
await prisma.allergen.deleteMany(); await prisma.allergen.deleteMany();
console.log('🌱 Seeding database with food-themed data...'); console.log('🌱 Seeding database with new recipes...');
// Allergens // Create ingredients first
const allergenNames = ['Gluten', 'Dairy', 'Tree Nuts', 'Eggs', 'Fish', 'Soy']; const ingredients = await Promise.all([
const allergens = await Promise.all( prisma.ingredient.create({ data: { name: 'Vincotto' } }),
allergenNames.map(name => prisma.ingredient.create({ data: { name: 'Olive Oil' } }),
prisma.allergen.create({ prisma.ingredient.create({ data: { name: 'Chives' } }),
data: { name, description: faker.food.description() }, 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' } }),
// Ingredients (mix of spices, vegetables, meats, fruits) prisma.ingredient.create({ data: { name: 'Honey' } }),
const ingredientNames = new Set<string>(); prisma.ingredient.create({ data: { name: 'Dijon' } }),
const ingredients: any[] = []; prisma.ingredient.create({ data: { name: 'Garlic' } }),
prisma.ingredient.create({ data: { name: 'Orange Zest' } }),
while (ingredientNames.size < 20) { prisma.ingredient.create({ data: { name: 'Water' } }),
const typePick = faker.helpers.arrayElement([ prisma.ingredient.create({ data: { name: 'White Wine Vinegar' } }),
() => faker.food.ingredient(), prisma.ingredient.create({ data: { name: 'Sugar' } }),
() => faker.food.spice(), prisma.ingredient.create({ data: { name: 'Salt' } }),
() => faker.food.vegetable(), prisma.ingredient.create({ data: { name: 'Nduja' } }),
() => faker.food.meat(), prisma.ingredient.create({ data: { name: 'Shallot Brunoise' } }),
() => faker.food.fruit(), prisma.ingredient.create({ data: { name: 'Butter' } }),
prisma.ingredient.create({ data: { name: 'Sundried Cherry Tomato' } })
]); ]);
const name = typePick(); // Create a map for easy ingredient lookup
if (!ingredientNames.has(name)) { const ingredientMap = new Map(ingredients.map(ing => [ing.name, ing]));
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);
}
}
// Recipes with realistic kitchen data // Recipe 1: Vincotto Dressing
const recipeData = [ const vincottoDressing = await prisma.recipe.create({
{
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',
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'
}
];
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: { data: {
name: recipeInfo.name, name: 'Vincotto Dressing',
description: recipeInfo.description, description: 'A rich and flavorful dressing made with vincotto, perfect for salads and marinades',
time: recipeInfo.time, time: 'Quick',
station: recipeInfo.station, station: 'Garde Manger',
instructions: faker.lorem.paragraph(), 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',
photoUrl: null,
ingredients: { ingredients: {
create: recipeIngredients.map(ing => ({ create: [
ingredientId: ing.id, { ingredientId: ingredientMap.get('Vincotto')!.id, quantity: 90, unit: 'ml' },
quantity: faker.number.int({ min: 10, max: 500 }), { ingredientId: ingredientMap.get('Olive Oil')!.id, quantity: 150, unit: 'ml' },
unit: faker.helpers.arrayElement(['g', 'ml', 'pcs']), { 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' }
]
}
}
}); });
})
);
// Menus // Recipe 2: Charred Orange Salad Dressing
const menus = await Promise.all( const charredOrangeDressing = await prisma.recipe.create({
['Spring', 'Summer', 'Autumn', 'Winter'].map(season => {
const seasonRecipes = faker.helpers.arrayElements(recipes, 3);
return prisma.menu.create({
data: { data: {
name: `${season} Menu: ${faker.food.ethnicCategory()}`, name: 'Charred Orange Salad Dressing',
season, description: 'A bright and citrusy dressing with charred orange notes',
recipes: { time: 'Medium',
create: seasonRecipes.map((rec, index) => ({ station: 'Garde Manger',
recipeId: rec.id, 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',
position: index + 1, 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' }
]
}
}
});
// 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' }
]
}
}
});
// 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('✅ Seeding complete!');
console.log('--- Summary ---'); console.log('--- Summary ---');
console.log(`Allergens created: ${allergens.length}`);
console.log(`Ingredients created: ${ingredients.length}`); console.log(`Ingredients created: ${ingredients.length}`);
console.log(`Recipes created: ${recipes.length}`); console.log(`Recipes created: 4`);
console.log(`Menus created: ${menus.length}`); console.log('Recipes:');
console.log('- Vincotto Dressing');
console.log('- Charred Orange Salad Dressing');
console.log('- Scallop Vinegar');
console.log('- Nduja Spread');
} }
main() main()

6
src/app.d.ts vendored
View File

@ -1,12 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {}
// interface Platform {} // interface Platform {}
interface Locals {
authenticated: boolean;
}
} }
} }

14
src/hooks.server.ts Normal file
View 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
View 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;
}

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

View File

@ -3,15 +3,21 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export let recipe: Recipe; export let recipe: Recipe;
export let authenticated: boolean = false;
function handleClick() { function handleClick() {
goto(`/recipe/${recipe.id}`); goto(`/recipe/${recipe.id}`);
} }
function handleEdit(e: Event) {
e.stopPropagation();
goto(`/recipe/${recipe.id}/edit`);
}
</script> </script>
<div <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" 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"> <div class="relative h-48 w-full overflow-hidden">
{#if recipe.photoUrl} {#if recipe.photoUrl}
@ -20,7 +26,7 @@
alt={recipe.name} alt={recipe.name}
loading="lazy" loading="lazy"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" 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; const target = e.target as HTMLImageElement;
target.style.display = 'none'; target.style.display = 'none';
target.nextElementSibling?.classList.remove('hidden'); target.nextElementSibling?.classList.remove('hidden');
@ -101,9 +107,30 @@
</svg> </svg>
{recipe.time || 'Medium'} {recipe.time || 'Medium'}
</span> </span>
<div class="flex items-center gap-2">
<span class="badge badge-sm badge-success"> <span class="badge badge-sm badge-success">
{recipe.station || 'Pans'} {recipe.station || 'Pans'}
</span> </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> </div>
</div> </div>

View File

@ -2,7 +2,23 @@ import type { LayoutServerLoad } from './$types';
import prisma from '$lib/server/prisma'; import prisma from '$lib/server/prisma';
export const load: LayoutServerLoad = async () => { export const load: LayoutServerLoad = async ({ locals }) => {
const recipes = await prisma.recipe.findMany(); // Get all recipes for search functionality
return { recipes }; const recipes = await prisma.recipe.findMany({
include: {
ingredients: {
include: {
ingredient: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
return {
recipes,
authenticated: locals.authenticated
};
}; };

View File

@ -3,6 +3,7 @@
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import logo from '$lib/assets/logo.webp'; import logo from '$lib/assets/logo.webp';
import SearchBar from '$lib/components/SearchBar.svelte'; import SearchBar from '$lib/components/SearchBar.svelte';
import LogoutButton from '$lib/components/LogoutButton.svelte';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
let { children, data } = $props<{ children: any; data: LayoutData }>(); let { children, data } = $props<{ children: any; data: LayoutData }>();
@ -23,13 +24,49 @@
</a> </a>
</div> </div>
<SearchBar recipes={data.recipes} /> <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> </div>
</header> </header>
<main class="flex-1"> <main class="flex-1">
{@render children?.()} {@render children?.()}
</main> </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> </div>
<style> <style>

View File

@ -1,11 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import RecipeCard from '$lib/components/RecipeCard.svelte'; import RecipeCard from '$lib/components/RecipeCard.svelte';
import { page } from '$app/stores';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
// Debug: Log the first recipe to see what fields are available // Debug: Log the first recipe to see what fields are available
console.log('First recipe data:', data.recipes[0]); 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> </script>
<svelte:head> <svelte:head>
@ -14,11 +22,51 @@
</svelte:head> </svelte:head>
<main class="m-0 min-h-screen bg-gradient-to-br from-base-200 to-base-300 p-0"> <main class="m-0 min-h-screen bg-gradient-to-br from-base-200 to-base-300 p-0">
{#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>
{/if}
{#if data.authenticated}
<section class="mx-auto max-w-7xl p-10 lg:p-6"> <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"> <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} {#each data.recipes as recipe}
<RecipeCard {recipe} /> <RecipeCard {recipe} authenticated={data.authenticated} />
{/each} {/each}
</div> </div>
</section> </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> </main>

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

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

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

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

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

View File

@ -81,6 +81,43 @@
</p> </p>
{/if} {/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="flex flex-col gap-4 sm:flex-row">
<div class="badge gap-2 badge-outline badge-lg"> <div class="badge gap-2 badge-outline badge-lg">
<svg <svg
@ -110,17 +147,19 @@
</svg> </svg>
<span class="font-semibold">{recipe.station}</span> <span class="font-semibold">{recipe.station}</span>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="font-semibold text-base-content/70">Scale</label> <label class="font-semibold text-base-content/70">Scale</label>
<span class="badge min-w-[3rem] text-center badge-primary">{scale}×</span>
</div>
<input <input
type="range" type="range"
min="0.25" min="0.5"
max="4" max="6"
step="0.25" step="0.5"
bind:value={scale} bind:value={scale}
class="range range-primary" class="range w-full range-primary"
/> />
<span class="badge">{scale}×</span>
</div> </div>
</div> </div>
</div> </div>

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

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

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

View File

@ -1,65 +1,15 @@
// import type { Actions } from './$types'; import type { PageServerLoad } from './$types';
// import { fail } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
// import prisma from '$lib/server/prisma';
// export const actions: Actions = { export const load: PageServerLoad = async ({ locals }) => {
// create: async ({ request }) => { // Check if user is authenticated
// const formData = await request.formData(); if (!locals.authenticated) {
throw redirect(302, '/');
}
// const name = (formData.get('name') as string | null)?.trim() ?? ''; return {
// const description = (formData.get('description') as string | null)?.trim() || null; authenticated: locals.authenticated
// 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}` };
// }
// };

View File

@ -1,9 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let pending = $state(false); let pending = $state(false);
let lastError = $state<string | null>(null); let lastError = $state<string | null>(null);
let lastSuccess = $state<string | null>(null); let lastSuccess = $state<string | null>(null);
let ingredientsText = $state(''); let ingredientsText = $state('');
let formSubmitted = $state(false); let formSubmitted = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
null
);
// Parsing functions for live preview // Parsing functions for live preview
function parseMixedNumber(input: string): number | null { function parseMixedNumber(input: string): number | null {
@ -227,6 +234,12 @@
lastError = null; lastError = null;
lastSuccess = null; lastSuccess = null;
formSubmitted = false; formSubmitted = false;
// Clear photo
if (selectedPhoto) {
URL.revokeObjectURL(selectedPhoto.preview);
selectedPhoto = null;
}
} }
async function onsubmit(event: SubmitEvent) { async function onsubmit(event: SubmitEvent) {
@ -267,6 +280,11 @@
// Add parsed ingredients as JSON // Add parsed ingredients as JSON
formData.append('parsedIngredients', JSON.stringify(parsedIngredients)); formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
// Add photo if selected
if (selectedPhoto) {
formData.append('photo', selectedPhoto.file);
}
// Submit to server // Submit to server
const response = await fetch('?/create', { const response = await fetch('?/create', {
method: 'POST', method: 'POST',
@ -298,10 +316,55 @@
pending = false; 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> </script>
{#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"> <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> <h1 class="mb-4 text-xl font-extrabold sm:mb-5 sm:text-2xl">Create New Recipe</h1>
<form <form
method="POST" method="POST"
@ -351,7 +414,43 @@
</div> </div>
<label class="form-control"> <label class="form-control">
<span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span <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 <textarea
@ -452,6 +551,36 @@
{/if} {/if}
</form> </form>
</div> </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> <style>
/* Utilities provided by Tailwind/daisyUI; no component-scoped CSS needed */ /* Utilities provided by Tailwind/daisyUI; no component-scoped CSS needed */

View File

@ -1,5 +1,16 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import prisma from '$lib/server/prisma'; 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 }) => { export const POST: RequestHandler = async ({ request }) => {
const formData = await request.formData(); 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 instructions = (formData.get('instructions') as string | null)?.trim() || null;
const time = ((formData.get('time') as string | null)?.trim() || 'Medium') as string; 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 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; const parsedIngredientsRaw = formData.get('parsedIngredients') as string | null;
let parsedIngredients: Array<{ name: string; quantity: number | null; unit: string | null; prep: 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 // Create recipe
const recipe = await prisma.recipe.create({ const recipe = await prisma.recipe.create({
data: { data: {
name, name,
description, description,
instructions, instructions,
photoUrl,
time, time,
station station
} }

View File

@ -4,4 +4,6 @@ import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
// Ensure environment variables are loaded
envPrefix: 'VITE_'
}); });