Compare commits

..

3 Commits

9 changed files with 488 additions and 112 deletions

View File

@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Recipe" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"instructions" TEXT,
"photoUrl" TEXT,
"time" TEXT NOT NULL DEFAULT 'Medium',
"station" TEXT NOT NULL DEFAULT 'Pans',
"hidden" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Recipe" ("createdAt", "description", "id", "instructions", "name", "photoUrl", "station", "time", "updatedAt") SELECT "createdAt", "description", "id", "instructions", "name", "photoUrl", "station", "time", "updatedAt" FROM "Recipe";
DROP TABLE "Recipe";
ALTER TABLE "new_Recipe" RENAME TO "Recipe";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -25,6 +25,7 @@ model Recipe {
photoUrl String?
time String @default("Medium") // Quick, Medium, Long
station String @default("Pans") // Garde Manger, Pans, Grill
hidden Boolean @default(false) // Hidden from non-chefs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -0,0 +1,200 @@
/**
* Client-side image compression utility
* Compresses images to reduce file size while maintaining quality
*/
export interface CompressionOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
maxSizeKB?: number;
format?: 'image/jpeg' | 'image/png' | 'image/webp';
}
export interface CompressionResult {
file: File;
originalSize: number;
compressedSize: number;
compressionRatio: number;
}
/**
* Compress an image file using HTML5 Canvas
*/
export async function compressImage(
file: File,
options: CompressionOptions = {}
): Promise<CompressionResult> {
const {
maxWidth = 1920,
maxHeight = 1080,
quality = 0.8,
maxSizeKB = 500, // Target 500KB to stay well under SvelteKit's 512KB limit
format = 'image/jpeg'
} = options;
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
try {
// Calculate new dimensions while maintaining aspect ratio
let { width, height } = img;
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
// Set canvas dimensions
canvas.width = width;
canvas.height = height;
// Draw and compress the image
ctx?.drawImage(img, 0, 0, width, height);
// Try different quality levels and dimensions if file is still too large
const tryCompress = (currentQuality: number, currentWidth: number, currentHeight: number): void => {
// Update canvas dimensions
canvas.width = currentWidth;
canvas.height = currentHeight;
// Redraw with new dimensions
ctx?.drawImage(img, 0, 0, currentWidth, currentHeight);
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to compress image'));
return;
}
const sizeKB = blob.size / 1024;
console.log(`Compression attempt: ${currentWidth}x${currentHeight}, quality: ${currentQuality}, size: ${sizeKB.toFixed(1)}KB, target: ${maxSizeKB}KB`);
// If still too large, try more aggressive compression
if (sizeKB > maxSizeKB) {
// First try reducing quality more aggressively
if (currentQuality > 0.1) {
tryCompress(Math.max(0.1, currentQuality - 0.15), currentWidth, currentHeight);
return;
}
// Then try reducing dimensions
else if (currentWidth > 400 && currentHeight > 300) {
const newWidth = Math.max(400, Math.floor(currentWidth * 0.8));
const newHeight = Math.max(300, Math.floor(currentHeight * 0.8));
tryCompress(0.1, newWidth, newHeight);
return;
}
// If we've exhausted all compression options and still too large, reject
else {
console.error(`Failed to compress image below ${maxSizeKB}KB. Final size: ${sizeKB.toFixed(1)}KB`);
reject(new Error(`Unable to compress image below ${maxSizeKB}KB. Please try a different image.`));
return;
}
}
// Create new file with compressed data
const compressedFile = new File([blob], file.name, {
type: format,
lastModified: Date.now()
});
console.log(`Compression successful: ${sizeKB.toFixed(1)}KB (${((blob.size / file.size) * 100).toFixed(1)}% of original)`);
resolve({
file: compressedFile,
originalSize: file.size,
compressedSize: blob.size,
compressionRatio: blob.size / file.size
});
},
format,
currentQuality
);
};
tryCompress(quality, width, height);
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
// Load the image
img.src = URL.createObjectURL(file);
});
}
/**
* Format file size for display
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Check if a file is an image
*/
export function isImageFile(file: File): boolean {
return file.type.startsWith('image/');
}
/**
* Get optimal compression settings based on file size
*/
export function getCompressionSettings(file: File): CompressionOptions {
const sizeMB = file.size / (1024 * 1024);
let settings: CompressionOptions;
if (sizeMB > 5) {
// Very large files - moderate compression (food photos need detail)
settings = {
maxWidth: 1200,
maxHeight: 900,
quality: 0.7,
maxSizeKB: 400
};
} else if (sizeMB > 2) {
// Large files - light compression
settings = {
maxWidth: 1400,
maxHeight: 1050,
quality: 0.75,
maxSizeKB: 450
};
} else if (sizeMB > 1) {
// Medium files - minimal compression
settings = {
maxWidth: 1600,
maxHeight: 1200,
quality: 0.8,
maxSizeKB: 480
};
} else {
// Smaller files - very light compression
settings = {
maxWidth: 1800,
maxHeight: 1350,
quality: 0.85,
maxSizeKB: 500
};
}
console.log(`Compression settings for ${sizeMB.toFixed(1)}MB file:`, settings);
return settings;
}

View File

@ -3,8 +3,10 @@ import type { LayoutServerLoad } from './$types';
import prisma from '$lib/server/prisma';
export const load: LayoutServerLoad = async ({ locals }) => {
// Get all recipes for search functionality
// Get recipes for search functionality
// If not authenticated, filter out hidden recipes
const recipes = await prisma.recipe.findMany({
where: locals.authenticated ? {} : { hidden: false },
include: {
ingredients: {
include: {

View File

@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import prisma from '$lib/server/prisma';
export const load: PageServerLoad = async ({ params }) => {
export const load: PageServerLoad = async ({ params, locals }) => {
const recipe = await prisma.recipe.findUnique({
where: { id: params.id },
include: {
@ -18,5 +18,10 @@ export const load: PageServerLoad = async ({ params }) => {
throw error(404, 'Recipe not found');
}
// Check if recipe is hidden and user is not authenticated
if (recipe.hidden && !locals.authenticated) {
throw error(404, 'Recipe not found');
}
return { recipe };
};

View File

@ -2,6 +2,12 @@
import { page } from '$app/stores';
import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown';
import {
compressImage,
formatFileSize,
isImageFile,
getCompressionSettings
} from '$lib/utils/imageCompression';
let { data }: { data: PageData } = $props();
@ -13,9 +19,15 @@
let instructionsText = $state('');
let formSubmitted = $state(false);
let showDeleteConfirm = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
null
);
let selectedPhoto = $state<{
file: File;
preview: string;
name: string;
originalSize: number;
compressedSize: number;
compressionRatio: number;
isCompressed: boolean;
} | null>(null);
let showMarkdownHelp = $state(false);
let showIngredientHelp = $state(false);
@ -40,34 +52,63 @@
});
// Photo handling functions
function handlePhotoChange(event: Event) {
async 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/')) {
if (!isImageFile(file)) {
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
};
// Validate file size (50MB limit for original files)
if (file.size > 50 * 1024 * 1024) {
lastError = 'Photo must be less than 50MB';
input.value = '';
return;
}
lastError = null;
try {
// Show loading state
const preview = URL.createObjectURL(file);
selectedPhoto = {
file,
preview,
name: file.name,
originalSize: file.size,
compressedSize: file.size,
compressionRatio: 1,
isCompressed: false
};
// Compress the image
const compressionSettings = getCompressionSettings(file);
const result = await compressImage(file, compressionSettings);
// Update with compressed file
selectedPhoto = {
file: result.file,
preview: URL.createObjectURL(result.file),
name: file.name,
originalSize: result.originalSize,
compressedSize: result.compressedSize,
compressionRatio: result.compressionRatio,
isCompressed: result.compressionRatio < 0.95
};
// Clean up the original preview URL
URL.revokeObjectURL(preview);
} catch (error) {
console.error('Error compressing image:', error);
lastError = 'Failed to process image. Please try a different file.';
input.value = '';
selectedPhoto = null;
}
}
}
@ -342,11 +383,21 @@
formData.set('description', descriptionText);
formData.set('instructions', instructionsText);
// Remove the original photo from FormData (if it exists)
formData.delete('photo');
// Add parsed ingredients as JSON
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
// Add photo if selected
// Add compressed photo if selected
if (selectedPhoto) {
console.log('Uploading compressed photo:', {
name: selectedPhoto.file.name,
size: selectedPhoto.file.size,
type: selectedPhoto.file.type,
originalSize: selectedPhoto.originalSize,
compressedSize: selectedPhoto.compressedSize
});
formData.append('photo', selectedPhoto.file);
}
@ -418,7 +469,7 @@
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"
class="card relative grid gap-4 overflow-hidden border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
>
{#if pending}
<div
@ -444,18 +495,18 @@
/>
</label>
<label class="form-control">
<label class="form-control min-w-0">
<span class="label"><span class="label-text font-bold">Time</span></span>
<select name="time" class="select-bordered select w-full">
<select name="time" class="select-bordered select w-full min-w-0">
<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">
<label class="form-control min-w-0">
<span class="label"><span class="label-text font-bold">Station</span></span>
<select name="station" class="select-bordered select w-full">
<select name="station" class="select-bordered select w-full min-w-0">
<option value="Garde Manger" selected={data.recipe?.station === 'Garde Manger'}
>Garde Manger</option
>
@ -465,56 +516,71 @@
</label>
</div>
<label class="form-control">
<label class="form-control min-w-0">
<div class="label">
<span class="label-text font-bold">Visibility</span>
</div>
<div class="flex items-center gap-3">
<input
type="checkbox"
name="hidden"
class="checkbox checkbox-primary"
checked={data.recipe?.hidden || false}
/>
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
</div>
</label>
<label class="form-control min-w-0">
<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"
class="file-input-bordered file-input w-full min-w-0 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="flex items-center gap-3 rounded-lg bg-base-200 p-3">
<div class="avatar">
<div class="w-24 rounded-lg">
<div class="w-20 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
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{selectedPhoto.name}</p>
<div class="text-xs text-base-content/60">
<p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
{#if selectedPhoto.isCompressed}
<p class="text-success">
{((1 - selectedPhoto.compressionRatio) * 100).toFixed(0)}% smaller
</p>
{/if}
</div>
<button type="button" class="btn mt-1 btn-xs btn-error" onclick={removePhoto}>
Remove
</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="flex items-center gap-3 rounded-lg bg-base-200 p-3">
<div class="avatar">
<div class="w-24 rounded-lg">
<div class="w-20 rounded-lg">
<img src={data.recipe.photoUrl} alt="Current recipe photo" class="object-cover" />
</div>
</div>
<div class="flex-1">
<div class="min-w-0 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">
<label class="form-control min-w-0">
<span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span
>
@ -524,7 +590,7 @@
placeholder="2 cups flour, sifted
3g salt
500ml water"
class="textarea-bordered textarea w-full"
class="textarea-bordered textarea w-full min-w-0"
bind:value={ingredientsText}
></textarea>
@ -544,16 +610,24 @@
{#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">
<div class="flex flex-wrap gap-1 overflow-hidden text-sm">
{#if parsed.quantity}
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
<span class="badge badge-sm whitespace-nowrap badge-primary"
>Qty: {parsed.quantity}</span
>
{/if}
{#if parsed.unit}
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
<span class="badge badge-sm whitespace-nowrap badge-secondary"
>Unit: {parsed.unit}</span
>
{/if}
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
<span class="badge max-w-32 truncate badge-sm whitespace-nowrap badge-accent"
>Name: {parsed.name}</span
>
{#if parsed.prep}
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
<span class="badge max-w-32 truncate badge-outline badge-sm whitespace-nowrap"
>Prep: {parsed.prep}</span
>
{/if}
</div>
</div>
@ -564,7 +638,7 @@
{/if}
</label>
<label class="form-control">
<label class="form-control min-w-0">
<span class="label">
<span class="label-text font-bold">Description</span>
<button
@ -578,7 +652,7 @@
<textarea
name="description"
rows="3"
class="textarea-bordered textarea w-full"
class="textarea-bordered textarea w-full min-w-0"
bind:value={descriptionText}
placeholder="Enter a description for your recipe"
></textarea>
@ -593,7 +667,7 @@
{/if}
</label>
<label class="form-control">
<label class="form-control min-w-0">
<span class="label">
<span class="label-text font-bold">Instructions</span>
<button
@ -607,7 +681,7 @@
<textarea
name="instructions"
rows="6"
class="textarea-bordered textarea w-full"
class="textarea-bordered textarea w-full min-w-0"
bind:value={instructionsText}
placeholder="Enter step-by-step instructions"
></textarea>

View File

@ -24,6 +24,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
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 hidden = formData.get('hidden') === 'on'; // Checkbox returns 'on' when checked
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 }> = [];
@ -99,7 +100,8 @@ export const POST: RequestHandler = async ({ request, params }) => {
instructions,
photoUrl: photoUrl || existingRecipe.photoUrl, // Keep existing photo if no new one uploaded
time,
station
station,
hidden
}
});

View File

@ -1,6 +1,12 @@
<script lang="ts">
import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown';
import {
compressImage,
formatFileSize,
isImageFile,
getCompressionSettings
} from '$lib/utils/imageCompression';
let { data }: { data: PageData } = $props();
@ -11,9 +17,15 @@
let descriptionText = $state('');
let instructionsText = $state('');
let formSubmitted = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>(
null
);
let selectedPhoto = $state<{
file: File;
preview: string;
name: string;
originalSize: number;
compressedSize: number;
compressionRatio: number;
isCompressed: boolean;
} | null>(null);
let showIngredientHelp = $state(false);
let showMarkdownHelp = $state(false);
@ -284,11 +296,21 @@
formData.set('description', descriptionText);
formData.set('instructions', instructionsText);
// Remove the original photo from FormData (if it exists)
formData.delete('photo');
// Add parsed ingredients as JSON
formData.append('parsedIngredients', JSON.stringify(parsedIngredients));
// Add photo if selected
// Add compressed photo if selected
if (selectedPhoto) {
console.log('Uploading compressed photo:', {
name: selectedPhoto.file.name,
size: selectedPhoto.file.size,
type: selectedPhoto.file.type,
originalSize: selectedPhoto.originalSize,
compressedSize: selectedPhoto.compressedSize
});
formData.append('photo', selectedPhoto.file);
}
@ -325,34 +347,63 @@
}
// Photo handling functions
function handlePhotoChange(event: Event) {
async 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/')) {
if (!isImageFile(file)) {
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
};
// Validate file size (50MB limit for original files)
if (file.size > 50 * 1024 * 1024) {
lastError = 'Photo must be less than 50MB';
input.value = '';
return;
}
lastError = null;
try {
// Show loading state
const preview = URL.createObjectURL(file);
selectedPhoto = {
file,
preview,
name: file.name,
originalSize: file.size,
compressedSize: file.size,
compressionRatio: 1,
isCompressed: false
};
// Compress the image
const compressionSettings = getCompressionSettings(file);
const result = await compressImage(file, compressionSettings);
// Update with compressed file
selectedPhoto = {
file: result.file,
preview: URL.createObjectURL(result.file),
name: file.name,
originalSize: result.originalSize,
compressedSize: result.compressedSize,
compressionRatio: result.compressionRatio,
isCompressed: result.compressionRatio < 0.95
};
// Clean up the original preview URL
URL.revokeObjectURL(preview);
} catch (error) {
console.error('Error compressing image:', error);
lastError = 'Failed to process image. Please try a different file.';
input.value = '';
selectedPhoto = null;
}
}
}
@ -377,7 +428,7 @@
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"
class="card relative grid gap-4 overflow-hidden border border-base-200 bg-base-100 p-3 shadow-xl sm:p-6"
>
{#if pending}
<div
@ -397,13 +448,13 @@
required
autocomplete="off"
inputmode="text"
class="input-bordered input w-full"
class="input-bordered input w-full min-w-0"
/>
</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">
<select name="time" class="select-bordered select w-full min-w-0">
<option>Quick</option>
<option selected>Medium</option>
<option>Long</option>
@ -412,7 +463,7 @@
<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">
<select name="station" class="select-bordered select w-full min-w-0">
<option>Garde Manger</option>
<option selected>Pans</option>
<option>Grill</option>
@ -420,42 +471,52 @@
</label>
</div>
<label class="form-control">
<label class="form-control min-w-0">
<div class="label">
<span class="label-text font-bold">Visibility</span>
</div>
<div class="flex items-center gap-3">
<input type="checkbox" name="hidden" class="checkbox checkbox-primary" />
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
</div>
</label>
<label class="form-control min-w-0">
<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"
class="file-input-bordered file-input w-full min-w-0 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="flex items-center gap-3 rounded-lg bg-base-200 p-3">
<div class="avatar">
<div class="w-24 rounded-lg">
<div class="w-20 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
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{selectedPhoto.name}</p>
<div class="text-xs text-base-content/60">
<p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
{#if selectedPhoto.isCompressed}
<p class="text-success">
{((1 - selectedPhoto.compressionRatio) * 100).toFixed(0)}% smaller
</p>
{/if}
</div>
<button type="button" class="btn mt-1 btn-xs btn-error" onclick={removePhoto}>
Remove
</button>
</div>
</div>
{/if}
<label class="form-control">
<label class="form-control min-w-0">
<span class="label"
><span class="label-text font-bold">Ingredients (one per line)</span></span
>
@ -466,7 +527,7 @@
placeholder="2 cups flour, sifted
3g salt
500ml water"
class="textarea-bordered textarea w-full"
class="textarea-bordered textarea w-full min-w-0"
bind:value={ingredientsText}
></textarea>
@ -487,16 +548,25 @@
{#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">
<div class="flex flex-wrap gap-1 overflow-hidden text-sm">
{#if parsed.quantity}
<span class="badge badge-sm badge-primary">Qty: {parsed.quantity}</span>
<span class="badge badge-sm whitespace-nowrap badge-primary"
>Qty: {parsed.quantity}</span
>
{/if}
{#if parsed.unit}
<span class="badge badge-sm badge-secondary">Unit: {parsed.unit}</span>
<span class="badge badge-sm whitespace-nowrap badge-secondary"
>Unit: {parsed.unit}</span
>
{/if}
<span class="badge badge-sm badge-accent">Name: {parsed.name}</span>
<span class="badge max-w-32 truncate badge-sm whitespace-nowrap badge-accent"
>Name: {parsed.name}</span
>
{#if parsed.prep}
<span class="badge badge-outline badge-sm">Prep: {parsed.prep}</span>
<span
class="badge max-w-32 truncate badge-outline badge-sm whitespace-nowrap"
>Prep: {parsed.prep}</span
>
{/if}
</div>
</div>
@ -506,7 +576,7 @@
</div>
{/if}
</label>
<label class="form-control">
<label class="form-control min-w-0">
<span class="label">
<span class="label-text font-bold">Description</span>
<button
@ -520,7 +590,7 @@
<textarea
name="description"
rows="3"
class="textarea-bordered textarea w-full"
class="textarea-bordered textarea w-full min-w-0"
bind:value={descriptionText}
placeholder="Enter a description for your recipe"
></textarea>
@ -535,7 +605,7 @@
{/if}
</label>
<label class="form-control">
<label class="form-control min-w-0">
<span class="label">
<span class="label-text font-bold">Instructions</span>
<button
@ -549,7 +619,7 @@
<textarea
name="instructions"
rows="6"
class="textarea-bordered textarea w-full"
class="textarea-bordered textarea w-full min-w-0"
bind:value={instructionsText}
placeholder="Enter step-by-step instructions"
></textarea>

View File

@ -24,6 +24,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 hidden = formData.get('hidden') === 'on'; // Checkbox returns 'on' when checked
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 }> = [];
@ -89,7 +90,8 @@ export const POST: RequestHandler = async ({ request }) => {
instructions,
photoUrl,
time,
station
station,
hidden
}
});