mirror of
https://github.com/taogaetz/chefbible.git
synced 2025-12-06 11:47:24 -05:00
Implement image compression for recipe photo uploads, updating file handling to support larger sizes and provide compression feedback. Adjusted UI to reflect new file size limits and compression status.
This commit is contained in:
parent
f8c205e9d3
commit
f46064452a
160
src/lib/utils/imageCompression.ts
Normal file
160
src/lib/utils/imageCompression.ts
Normal file
@ -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<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 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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 @@
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-sm text-base-content/60">
|
||||
Supports: JPG, PNG, GIF, WebP (max 10MB)
|
||||
Supports: JPG, PNG, GIF, WebP (max 50MB, auto-compressed for upload)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
@ -510,9 +551,15 @@
|
||||
</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>
|
||||
<div class="space-y-1 text-xs text-base-content/60">
|
||||
<p>Upload size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
||||
{#if selectedPhoto.isCompressed}
|
||||
<p class="text-success">
|
||||
Compressed from {formatFileSize(selectedPhoto.originalSize)}
|
||||
({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction)
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
|
||||
Remove Photo
|
||||
</button>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<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 +12,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);
|
||||
|
||||
@ -325,34 +332,64 @@
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -446,7 +483,7 @@
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-sm text-base-content/60">
|
||||
Supports: JPG, PNG, GIF, WebP (max 10MB)
|
||||
Supports: JPG, PNG, GIF, WebP (max 50MB, auto-compressed for upload)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
@ -460,9 +497,15 @@
|
||||
</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>
|
||||
<div class="text-xs text-base-content/60 space-y-1">
|
||||
<p>Upload size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
||||
{#if selectedPhoto.isCompressed}
|
||||
<p class="text-success">
|
||||
Compressed from {formatFileSize(selectedPhoto.originalSize)}
|
||||
({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction)
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
|
||||
Remove Photo
|
||||
</button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user