mirror of
https://github.com/taogaetz/chefbible.git
synced 2025-12-06 11:47:24 -05:00
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:
parent
f46064452a
commit
ec24b39d1a
@ -56,8 +56,15 @@ export async function compressImage(
|
|||||||
// Draw and compress the image
|
// Draw and compress the image
|
||||||
ctx?.drawImage(img, 0, 0, width, height);
|
ctx?.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
// Try different quality levels if file is still too large
|
// Try different quality levels and dimensions if file is still too large
|
||||||
const tryCompress = (currentQuality: number): void => {
|
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(
|
canvas.toBlob(
|
||||||
(blob) => {
|
(blob) => {
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
@ -67,10 +74,28 @@ export async function compressImage(
|
|||||||
|
|
||||||
const sizeKB = blob.size / 1024;
|
const sizeKB = blob.size / 1024;
|
||||||
|
|
||||||
// If still too large and we can reduce quality further, try again
|
console.log(`Compression attempt: ${currentWidth}x${currentHeight}, quality: ${currentQuality}, size: ${sizeKB.toFixed(1)}KB, target: ${maxSizeKB}KB`);
|
||||||
if (sizeKB > maxSizeKB && currentQuality > 0.3) {
|
|
||||||
tryCompress(currentQuality - 0.1);
|
// If still too large, try more aggressive compression
|
||||||
return;
|
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
|
// Create new file with compressed data
|
||||||
@ -79,6 +104,8 @@ export async function compressImage(
|
|||||||
lastModified: Date.now()
|
lastModified: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`Compression successful: ${sizeKB.toFixed(1)}KB (${((blob.size / file.size) * 100).toFixed(1)}% of original)`);
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
file: compressedFile,
|
file: compressedFile,
|
||||||
originalSize: file.size,
|
originalSize: file.size,
|
||||||
@ -91,7 +118,7 @@ export async function compressImage(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
tryCompress(quality);
|
tryCompress(quality, width, height);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
}
|
}
|
||||||
@ -132,29 +159,42 @@ export function isImageFile(file: File): boolean {
|
|||||||
export function getCompressionSettings(file: File): CompressionOptions {
|
export function getCompressionSettings(file: File): CompressionOptions {
|
||||||
const sizeMB = file.size / (1024 * 1024);
|
const sizeMB = file.size / (1024 * 1024);
|
||||||
|
|
||||||
|
let settings: CompressionOptions;
|
||||||
|
|
||||||
if (sizeMB > 5) {
|
if (sizeMB > 5) {
|
||||||
// Very large files - aggressive compression
|
// Very large files - moderate compression (food photos need detail)
|
||||||
return {
|
settings = {
|
||||||
maxWidth: 1200,
|
maxWidth: 1200,
|
||||||
maxHeight: 800,
|
maxHeight: 900,
|
||||||
quality: 0.6,
|
quality: 0.7,
|
||||||
maxSizeKB: 400
|
maxSizeKB: 400
|
||||||
};
|
};
|
||||||
} else if (sizeMB > 2) {
|
} else if (sizeMB > 2) {
|
||||||
// Large files - moderate compression
|
// Large files - light compression
|
||||||
return {
|
settings = {
|
||||||
maxWidth: 1600,
|
maxWidth: 1400,
|
||||||
maxHeight: 1000,
|
maxHeight: 1050,
|
||||||
quality: 0.7,
|
quality: 0.75,
|
||||||
maxSizeKB: 450
|
maxSizeKB: 450
|
||||||
};
|
};
|
||||||
} else {
|
} else if (sizeMB > 1) {
|
||||||
// Smaller files - light compression
|
// Medium files - minimal compression
|
||||||
return {
|
settings = {
|
||||||
maxWidth: 1920,
|
maxWidth: 1600,
|
||||||
maxHeight: 1080,
|
maxHeight: 1200,
|
||||||
quality: 0.8,
|
quality: 0.8,
|
||||||
|
maxSizeKB: 480
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Smaller files - very light compression
|
||||||
|
settings = {
|
||||||
|
maxWidth: 1800,
|
||||||
|
maxHeight: 1350,
|
||||||
|
quality: 0.85,
|
||||||
maxSizeKB: 500
|
maxSizeKB: 500
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Compression settings for ${sizeMB.toFixed(1)}MB file:`, settings);
|
||||||
|
return settings;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -383,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,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
|
||||||
@ -485,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
|
||||||
>
|
>
|
||||||
@ -506,7 +516,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<span class="label-text font-bold">Visibility</span>
|
<span class="label-text font-bold">Visibility</span>
|
||||||
</div>
|
</div>
|
||||||
@ -519,69 +529,58 @@
|
|||||||
/>
|
/>
|
||||||
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
|
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<label class="form-control">
|
<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 50MB, auto-compressed for upload)
|
|
||||||
</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>
|
||||||
<div class="space-y-1 text-xs text-base-content/60">
|
<div class="text-xs text-base-content/60">
|
||||||
<p>Upload size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
<p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
||||||
{#if selectedPhoto.isCompressed}
|
{#if selectedPhoto.isCompressed}
|
||||||
<p class="text-success">
|
<p class="text-success">
|
||||||
Compressed from {formatFileSize(selectedPhoto.originalSize)}
|
{((1 - selectedPhoto.compressionRatio) * 100).toFixed(0)}% smaller
|
||||||
({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction)
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
|
<button type="button" class="btn mt-1 btn-xs btn-error" onclick={removePhoto}>
|
||||||
Remove Photo
|
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
|
||||||
>
|
>
|
||||||
|
|
||||||
@ -591,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>
|
||||||
|
|
||||||
@ -611,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>
|
||||||
@ -631,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
|
||||||
@ -645,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>
|
||||||
@ -660,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
|
||||||
@ -674,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>
|
||||||
|
|||||||
@ -1,7 +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';
|
import {
|
||||||
|
compressImage,
|
||||||
|
formatFileSize,
|
||||||
|
isImageFile,
|
||||||
|
getCompressionSettings
|
||||||
|
} from '$lib/utils/imageCompression';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@ -12,10 +17,10 @@
|
|||||||
let descriptionText = $state('');
|
let descriptionText = $state('');
|
||||||
let instructionsText = $state('');
|
let instructionsText = $state('');
|
||||||
let formSubmitted = $state(false);
|
let formSubmitted = $state(false);
|
||||||
let selectedPhoto = $state<{
|
let selectedPhoto = $state<{
|
||||||
file: File;
|
file: File;
|
||||||
preview: string;
|
preview: string;
|
||||||
name: string;
|
name: string;
|
||||||
originalSize: number;
|
originalSize: number;
|
||||||
compressedSize: number;
|
compressedSize: number;
|
||||||
compressionRatio: number;
|
compressionRatio: number;
|
||||||
@ -291,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,7 +367,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastError = null;
|
lastError = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
const preview = URL.createObjectURL(file);
|
const preview = URL.createObjectURL(file);
|
||||||
@ -383,7 +398,6 @@
|
|||||||
|
|
||||||
// Clean up the original preview URL
|
// Clean up the original preview URL
|
||||||
URL.revokeObjectURL(preview);
|
URL.revokeObjectURL(preview);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error compressing image:', error);
|
console.error('Error compressing image:', error);
|
||||||
lastError = 'Failed to process image. Please try a different file.';
|
lastError = 'Failed to process image. Please try a different file.';
|
||||||
@ -414,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
|
||||||
@ -434,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>
|
||||||
@ -449,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>
|
||||||
@ -457,7 +471,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control min-w-0">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<span class="label-text font-bold">Visibility</span>
|
<span class="label-text font-bold">Visibility</span>
|
||||||
</div>
|
</div>
|
||||||
@ -465,55 +479,44 @@
|
|||||||
<input type="checkbox" name="hidden" class="checkbox checkbox-primary" />
|
<input type="checkbox" name="hidden" class="checkbox checkbox-primary" />
|
||||||
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
|
<span class="label-text">Hide from non-chefs (chef-only recipe)</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<label class="form-control">
|
<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 50MB, auto-compressed for upload)
|
|
||||||
</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>
|
||||||
<div class="text-xs text-base-content/60 space-y-1">
|
<div class="text-xs text-base-content/60">
|
||||||
<p>Upload size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
<p>Size: {formatFileSize(selectedPhoto.compressedSize)}</p>
|
||||||
{#if selectedPhoto.isCompressed}
|
{#if selectedPhoto.isCompressed}
|
||||||
<p class="text-success">
|
<p class="text-success">
|
||||||
Compressed from {formatFileSize(selectedPhoto.originalSize)}
|
{((1 - selectedPhoto.compressionRatio) * 100).toFixed(0)}% smaller
|
||||||
({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction)
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
|
<button type="button" class="btn mt-1 btn-xs btn-error" onclick={removePhoto}>
|
||||||
Remove Photo
|
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
|
||||||
>
|
>
|
||||||
@ -524,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>
|
||||||
|
|
||||||
@ -545,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>
|
||||||
@ -564,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
|
||||||
@ -578,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>
|
||||||
@ -593,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
|
||||||
@ -607,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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user