Enhance image compression logic for recipe uploads by allowing dynamic adjustment of quality and dimensions. Update UI to reflect changes in photo handling, including improved feedback on compression status and file sizes. Adjust form elements for better responsiveness and usability.

This commit is contained in:
taogaetz 2025-09-11 13:19:12 -04:00
parent f46064452a
commit ec24b39d1a
3 changed files with 169 additions and 110 deletions

View File

@ -56,8 +56,15 @@ export async function compressImage(
// 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 => {
// 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) {
@ -67,11 +74,29 @@ export async function compressImage(
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);
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, {
@ -79,6 +104,8 @@ export async function compressImage(
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,
@ -91,7 +118,7 @@ export async function compressImage(
);
};
tryCompress(quality);
tryCompress(quality, width, height);
} catch (error) {
reject(error);
}
@ -132,29 +159,42 @@ export function isImageFile(file: File): boolean {
export function getCompressionSettings(file: File): CompressionOptions {
const sizeMB = file.size / (1024 * 1024);
let settings: CompressionOptions;
if (sizeMB > 5) {
// Very large files - aggressive compression
return {
// Very large files - moderate compression (food photos need detail)
settings = {
maxWidth: 1200,
maxHeight: 800,
quality: 0.6,
maxHeight: 900,
quality: 0.7,
maxSizeKB: 400
};
} else if (sizeMB > 2) {
// Large files - moderate compression
return {
maxWidth: 1600,
maxHeight: 1000,
quality: 0.7,
// Large files - light compression
settings = {
maxWidth: 1400,
maxHeight: 1050,
quality: 0.75,
maxSizeKB: 450
};
} else {
// Smaller files - light compression
return {
maxWidth: 1920,
maxHeight: 1080,
} 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

@ -383,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);
}
@ -459,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
@ -485,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
>
@ -506,7 +516,7 @@
</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>
@ -519,69 +529,58 @@
/>
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
</div>
<div class="label">
<span class="label-text-alt text-sm text-base-content/60">
Hidden recipes will only be visible to authenticated chefs
</span>
</div>
</label>
<label class="form-control">
<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 50MB, auto-compressed for upload)
</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>
<div class="space-y-1 text-xs text-base-content/60">
<p>Upload size: {formatFileSize(selectedPhoto.compressedSize)}</p>
<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">
Compressed from {formatFileSize(selectedPhoto.originalSize)}
({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction)
{((1 - selectedPhoto.compressionRatio) * 100).toFixed(0)}% smaller
</p>
{/if}
</div>
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
Remove Photo
<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
>
@ -591,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>
@ -611,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>
@ -631,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
@ -645,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>
@ -660,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
@ -674,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

@ -1,7 +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';
import {
compressImage,
formatFileSize,
isImageFile,
getCompressionSettings
} from '$lib/utils/imageCompression';
let { data }: { data: PageData } = $props();
@ -291,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);
}
@ -383,7 +398,6 @@
// 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.';
@ -414,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
@ -434,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>
@ -449,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>
@ -457,7 +471,7 @@
</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>
@ -465,55 +479,44 @@
<input type="checkbox" name="hidden" class="checkbox checkbox-primary" />
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
</div>
<div class="label">
<span class="label-text-alt text-sm text-base-content/60">
Hidden recipes will only be visible to authenticated chefs
</span>
</div>
</label>
<label class="form-control">
<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 50MB, auto-compressed for upload)
</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>
<div class="text-xs text-base-content/60 space-y-1">
<p>Upload size: {formatFileSize(selectedPhoto.compressedSize)}</p>
<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">
Compressed from {formatFileSize(selectedPhoto.originalSize)}
({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction)
{((1 - selectedPhoto.compressionRatio) * 100).toFixed(0)}% smaller
</p>
{/if}
</div>
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
Remove Photo
<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
>
@ -524,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>
@ -545,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>
@ -564,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
@ -578,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>
@ -593,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
@ -607,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>