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? photoUrl String?
time String @default("Medium") // Quick, Medium, Long time String @default("Medium") // Quick, Medium, Long
station String @default("Pans") // Garde Manger, Pans, Grill station String @default("Pans") // Garde Manger, Pans, Grill
hidden Boolean @default(false) // Hidden from non-chefs
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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'; import prisma from '$lib/server/prisma';
export const load: LayoutServerLoad = async ({ locals }) => { 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({ const recipes = await prisma.recipe.findMany({
where: locals.authenticated ? {} : { hidden: false },
include: { include: {
ingredients: { ingredients: {
include: { include: {

View File

@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import prisma from '$lib/server/prisma'; 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({ const recipe = await prisma.recipe.findUnique({
where: { id: params.id }, where: { id: params.id },
include: { include: {
@ -18,5 +18,10 @@ export const load: PageServerLoad = async ({ params }) => {
throw error(404, 'Recipe not found'); 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 }; return { recipe };
}; };

View File

@ -2,6 +2,12 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown'; import { renderMarkdownToHTML } from '$lib/utils/markdown';
import {
compressImage,
formatFileSize,
isImageFile,
getCompressionSettings
} from '$lib/utils/imageCompression';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -13,9 +19,15 @@
let instructionsText = $state(''); let instructionsText = $state('');
let formSubmitted = $state(false); let formSubmitted = $state(false);
let showDeleteConfirm = $state(false); let showDeleteConfirm = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>( let selectedPhoto = $state<{
null file: File;
); preview: string;
name: string;
originalSize: number;
compressedSize: number;
compressionRatio: number;
isCompressed: boolean;
} | null>(null);
let showMarkdownHelp = $state(false); let showMarkdownHelp = $state(false);
let showIngredientHelp = $state(false); let showIngredientHelp = $state(false);
@ -40,34 +52,63 @@
}); });
// Photo handling functions // Photo handling functions
function handlePhotoChange(event: Event) { async function handlePhotoChange(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
if (file) { 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 // Validate file type
if (!file.type.startsWith('image/')) { if (!isImageFile(file)) {
lastError = 'Please select a valid image file'; lastError = 'Please select a valid image file';
input.value = ''; input.value = '';
return; return;
} }
// Create preview URL // Validate file size (50MB limit for original files)
const preview = URL.createObjectURL(file); if (file.size > 50 * 1024 * 1024) {
selectedPhoto = { lastError = 'Photo must be less than 50MB';
file, input.value = '';
preview, return;
name: file.name, }
size: file.size
};
lastError = null; 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('description', descriptionText);
formData.set('instructions', instructionsText); formData.set('instructions', instructionsText);
// Remove the original photo from FormData (if it exists)
formData.delete('photo');
// 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 // Add compressed photo if selected
if (selectedPhoto) { 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); formData.append('photo', selectedPhoto.file);
} }
@ -418,7 +469,7 @@
method="POST" method="POST"
action="?/update" action="?/update"
{onsubmit} {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} {#if pending}
<div <div
@ -444,18 +495,18 @@
/> />
</label> </label>
<label class="form-control"> <label class="form-control min-w-0">
<span class="label"><span class="label-text font-bold">Time</span></span> <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="Quick" selected={data.recipe?.time === 'Quick'}>Quick</option>
<option value="Medium" selected={data.recipe?.time === 'Medium'}>Medium</option> <option value="Medium" selected={data.recipe?.time === 'Medium'}>Medium</option>
<option value="Long" selected={data.recipe?.time === 'Long'}>Long</option> <option value="Long" selected={data.recipe?.time === 'Long'}>Long</option>
</select> </select>
</label> </label>
<label class="form-control"> <label class="form-control min-w-0">
<span class="label"><span class="label-text font-bold">Station</span></span> <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'} <option value="Garde Manger" selected={data.recipe?.station === 'Garde Manger'}
>Garde Manger</option >Garde Manger</option
> >
@ -465,56 +516,71 @@
</label> </label>
</div> </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> <span class="label"><span class="label-text font-bold">Photo (optional)</span></span>
<input <input
type="file" type="file"
name="photo" name="photo"
accept="image/*" 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} 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> </label>
{#if selectedPhoto} {#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="avatar">
<div class="w-24 rounded-lg"> <div class="w-20 rounded-lg">
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" /> <img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
</div> </div>
</div> </div>
<div class="flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium">{selectedPhoto.name}</p> <p class="truncate text-sm font-medium">{selectedPhoto.name}</p>
<p class="text-xs text-base-content/60"> <div class="text-xs text-base-content/60">
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB <p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
</p> {#if selectedPhoto.isCompressed}
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}> <p class="text-success">
Remove Photo {((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> </button>
</div> </div>
</div> </div>
{/if} {/if}
{#if data.recipe?.photoUrl && !selectedPhoto} {#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="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" /> <img src={data.recipe.photoUrl} alt="Current recipe photo" class="object-cover" />
</div> </div>
</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-sm font-medium">Current Photo</p>
<p class="text-xs text-base-content/60">Upload a new photo to replace this one</p> <p class="text-xs text-base-content/60">Upload a new photo to replace this one</p>
</div> </div>
</div> </div>
{/if} {/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 <span class="label"><span class="label-text font-bold">Ingredients (one per line)</span></span
> >
@ -524,7 +590,7 @@
placeholder="2 cups flour, sifted placeholder="2 cups flour, sifted
3g salt 3g salt
500ml water" 500ml water"
class="textarea-bordered textarea w-full" class="textarea-bordered textarea w-full min-w-0"
bind:value={ingredientsText} bind:value={ingredientsText}
></textarea> ></textarea>
@ -544,16 +610,24 @@
{#if parsed} {#if parsed}
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2"> <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="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} {#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}
{#if parsed.unit} {#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} {/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} {#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} {/if}
</div> </div>
</div> </div>
@ -564,7 +638,7 @@
{/if} {/if}
</label> </label>
<label class="form-control"> <label class="form-control min-w-0">
<span class="label"> <span class="label">
<span class="label-text font-bold">Description</span> <span class="label-text font-bold">Description</span>
<button <button
@ -578,7 +652,7 @@
<textarea <textarea
name="description" name="description"
rows="3" rows="3"
class="textarea-bordered textarea w-full" class="textarea-bordered textarea w-full min-w-0"
bind:value={descriptionText} bind:value={descriptionText}
placeholder="Enter a description for your recipe" placeholder="Enter a description for your recipe"
></textarea> ></textarea>
@ -593,7 +667,7 @@
{/if} {/if}
</label> </label>
<label class="form-control"> <label class="form-control min-w-0">
<span class="label"> <span class="label">
<span class="label-text font-bold">Instructions</span> <span class="label-text font-bold">Instructions</span>
<button <button
@ -607,7 +681,7 @@
<textarea <textarea
name="instructions" name="instructions"
rows="6" rows="6"
class="textarea-bordered textarea w-full" class="textarea-bordered textarea w-full min-w-0"
bind:value={instructionsText} bind:value={instructionsText}
placeholder="Enter step-by-step instructions" placeholder="Enter step-by-step instructions"
></textarea> ></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 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 hidden = formData.get('hidden') === 'on'; // Checkbox returns 'on' when checked
const photo = formData.get('photo') as File | null; 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 }> = [];
@ -99,7 +100,8 @@ export const POST: RequestHandler = async ({ request, params }) => {
instructions, instructions,
photoUrl: photoUrl || existingRecipe.photoUrl, // Keep existing photo if no new one uploaded photoUrl: photoUrl || existingRecipe.photoUrl, // Keep existing photo if no new one uploaded
time, time,
station station,
hidden
} }
}); });

View File

@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown'; import { renderMarkdownToHTML } from '$lib/utils/markdown';
import {
compressImage,
formatFileSize,
isImageFile,
getCompressionSettings
} from '$lib/utils/imageCompression';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -11,9 +17,15 @@
let descriptionText = $state(''); let descriptionText = $state('');
let instructionsText = $state(''); let instructionsText = $state('');
let formSubmitted = $state(false); let formSubmitted = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>( let selectedPhoto = $state<{
null file: File;
); preview: string;
name: string;
originalSize: number;
compressedSize: number;
compressionRatio: number;
isCompressed: boolean;
} | null>(null);
let showIngredientHelp = $state(false); let showIngredientHelp = $state(false);
let showMarkdownHelp = $state(false); let showMarkdownHelp = $state(false);
@ -284,11 +296,21 @@
formData.set('description', descriptionText); formData.set('description', descriptionText);
formData.set('instructions', instructionsText); formData.set('instructions', instructionsText);
// Remove the original photo from FormData (if it exists)
formData.delete('photo');
// 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 // Add compressed photo if selected
if (selectedPhoto) { 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); formData.append('photo', selectedPhoto.file);
} }
@ -325,34 +347,63 @@
} }
// Photo handling functions // Photo handling functions
function handlePhotoChange(event: Event) { async function handlePhotoChange(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
if (file) { 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 // Validate file type
if (!file.type.startsWith('image/')) { if (!isImageFile(file)) {
lastError = 'Please select a valid image file'; lastError = 'Please select a valid image file';
input.value = ''; input.value = '';
return; return;
} }
// Create preview URL // Validate file size (50MB limit for original files)
const preview = URL.createObjectURL(file); if (file.size > 50 * 1024 * 1024) {
selectedPhoto = { lastError = 'Photo must be less than 50MB';
file, input.value = '';
preview, return;
name: file.name, }
size: file.size
};
lastError = null; 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" method="POST"
action="?/create" action="?/create"
{onsubmit} {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} {#if pending}
<div <div
@ -397,13 +448,13 @@
required required
autocomplete="off" autocomplete="off"
inputmode="text" inputmode="text"
class="input-bordered input w-full" class="input-bordered input w-full min-w-0"
/> />
</label> </label>
<label class="form-control"> <label class="form-control">
<span class="label"><span class="label-text font-bold">Time</span></span> <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>Quick</option>
<option selected>Medium</option> <option selected>Medium</option>
<option>Long</option> <option>Long</option>
@ -412,7 +463,7 @@
<label class="form-control"> <label class="form-control">
<span class="label"><span class="label-text font-bold">Station</span></span> <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>Garde Manger</option>
<option selected>Pans</option> <option selected>Pans</option>
<option>Grill</option> <option>Grill</option>
@ -420,42 +471,52 @@
</label> </label>
</div> </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> <span class="label"><span class="label-text font-bold">Photo (optional)</span></span>
<input <input
type="file" type="file"
name="photo" name="photo"
accept="image/*" 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} 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> </label>
{#if selectedPhoto} {#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="avatar">
<div class="w-24 rounded-lg"> <div class="w-20 rounded-lg">
<img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" /> <img src={selectedPhoto.preview} alt="Recipe preview" class="object-cover" />
</div> </div>
</div> </div>
<div class="flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium">{selectedPhoto.name}</p> <p class="truncate text-sm font-medium">{selectedPhoto.name}</p>
<p class="text-xs text-base-content/60"> <div class="text-xs text-base-content/60">
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB <p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
</p> {#if selectedPhoto.isCompressed}
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}> <p class="text-success">
Remove Photo {((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> </button>
</div> </div>
</div> </div>
{/if} {/if}
<label class="form-control"> <label class="form-control min-w-0">
<span class="label" <span class="label"
><span class="label-text font-bold">Ingredients (one per line)</span></span ><span class="label-text font-bold">Ingredients (one per line)</span></span
> >
@ -466,7 +527,7 @@
placeholder="2 cups flour, sifted placeholder="2 cups flour, sifted
3g salt 3g salt
500ml water" 500ml water"
class="textarea-bordered textarea w-full" class="textarea-bordered textarea w-full min-w-0"
bind:value={ingredientsText} bind:value={ingredientsText}
></textarea> ></textarea>
@ -487,16 +548,25 @@
{#if parsed} {#if parsed}
<div class="mb-2 rounded border-l-4 border-primary bg-base-100 p-2"> <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="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} {#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}
{#if parsed.unit} {#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} {/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} {#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} {/if}
</div> </div>
</div> </div>
@ -506,7 +576,7 @@
</div> </div>
{/if} {/if}
</label> </label>
<label class="form-control"> <label class="form-control min-w-0">
<span class="label"> <span class="label">
<span class="label-text font-bold">Description</span> <span class="label-text font-bold">Description</span>
<button <button
@ -520,7 +590,7 @@
<textarea <textarea
name="description" name="description"
rows="3" rows="3"
class="textarea-bordered textarea w-full" class="textarea-bordered textarea w-full min-w-0"
bind:value={descriptionText} bind:value={descriptionText}
placeholder="Enter a description for your recipe" placeholder="Enter a description for your recipe"
></textarea> ></textarea>
@ -535,7 +605,7 @@
{/if} {/if}
</label> </label>
<label class="form-control"> <label class="form-control min-w-0">
<span class="label"> <span class="label">
<span class="label-text font-bold">Instructions</span> <span class="label-text font-bold">Instructions</span>
<button <button
@ -549,7 +619,7 @@
<textarea <textarea
name="instructions" name="instructions"
rows="6" rows="6"
class="textarea-bordered textarea w-full" class="textarea-bordered textarea w-full min-w-0"
bind:value={instructionsText} bind:value={instructionsText}
placeholder="Enter step-by-step instructions" placeholder="Enter step-by-step instructions"
></textarea> ></textarea>

View File

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