diff --git a/src/lib/utils/imageCompression.ts b/src/lib/utils/imageCompression.ts new file mode 100644 index 0000000..a7400fb --- /dev/null +++ b/src/lib/utils/imageCompression.ts @@ -0,0 +1,160 @@ +/** + * 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 { + 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 if file is still too large + const tryCompress = (currentQuality: number): void => { + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Failed to compress image')); + return; + } + + const sizeKB = blob.size / 1024; + + // If still too large and we can reduce quality further, try again + if (sizeKB > maxSizeKB && currentQuality > 0.3) { + tryCompress(currentQuality - 0.1); + return; + } + + // Create new file with compressed data + const compressedFile = new File([blob], file.name, { + type: format, + lastModified: Date.now() + }); + + resolve({ + file: compressedFile, + originalSize: file.size, + compressedSize: blob.size, + compressionRatio: blob.size / file.size + }); + }, + format, + currentQuality + ); + }; + + tryCompress(quality); + } 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); + + if (sizeMB > 5) { + // Very large files - aggressive compression + return { + maxWidth: 1200, + maxHeight: 800, + quality: 0.6, + maxSizeKB: 400 + }; + } else if (sizeMB > 2) { + // Large files - moderate compression + return { + maxWidth: 1600, + maxHeight: 1000, + quality: 0.7, + maxSizeKB: 450 + }; + } else { + // Smaller files - light compression + return { + maxWidth: 1920, + maxHeight: 1080, + quality: 0.8, + maxSizeKB: 500 + }; + } +} diff --git a/src/routes/recipe/[id]/edit/+page.svelte b/src/routes/recipe/[id]/edit/+page.svelte index 0282b77..c755ca7 100644 --- a/src/routes/recipe/[id]/edit/+page.svelte +++ b/src/routes/recipe/[id]/edit/+page.svelte @@ -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; + } } } @@ -496,7 +537,7 @@ />
- Supports: JPG, PNG, GIF, WebP (max 10MB) + Supports: JPG, PNG, GIF, WebP (max 50MB, auto-compressed for upload)
@@ -510,9 +551,15 @@

{selectedPhoto.name}

-

- {(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB -

+
+

Upload size: {formatFileSize(selectedPhoto.compressedSize)}

+ {#if selectedPhoto.isCompressed} +

+ Compressed from {formatFileSize(selectedPhoto.originalSize)} + ({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction) +

+ {/if} +
diff --git a/src/routes/recipe/new/+page.svelte b/src/routes/recipe/new/+page.svelte index 5321403..736aa33 100644 --- a/src/routes/recipe/new/+page.svelte +++ b/src/routes/recipe/new/+page.svelte @@ -1,6 +1,7 @@