mirror of
https://github.com/taogaetz/chefbible.git
synced 2025-12-06 11:47:24 -05:00
Compare commits
3 Commits
3d043d9ade
...
ec24b39d1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec24b39d1a | ||
|
|
f46064452a | ||
|
|
f8c205e9d3 |
@ -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;
|
||||
@ -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
|
||||
|
||||
|
||||
200
src/lib/utils/imageCompression.ts
Normal file
200
src/lib/utils/imageCompression.ts
Normal 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;
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user