refactor: defer recipe image upload until form submission
Changed recipe image upload behavior to only process images when the form is submitted, rather than immediately on file selection. This prevents orphaned image files when users abandon the form. Changes: - CardAdd.svelte: Preview only, store File object instead of uploading - Created imageProcessing.ts: Shared utility for image processing - Add/edit page clients: Use selected_image_file instead of filename - Add/edit page servers: Process and save images during form submission - Images are validated, hashed, and saved in multiple formats on submit Benefits: - No orphaned files from abandoned forms - Faster initial file selection experience - Server-side image processing ensures security validation - Cleaner architecture with shared processing logic
This commit is contained in:
@@ -3,6 +3,8 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { Recipe } from '$models/Recipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { invalidateRecipeCaches } from '$lib/server/cache';
|
||||
import { IMAGE_DIR } from '$env/static/private';
|
||||
import { processAndSaveRecipeImage } from '$utils/imageProcessing';
|
||||
import {
|
||||
extractRecipeFromFormData,
|
||||
validateRecipeData,
|
||||
@@ -49,14 +51,29 @@ export const actions = {
|
||||
}
|
||||
|
||||
// Handle optional image upload
|
||||
const uploadedImage = formData.get('uploaded_image_filename')?.toString();
|
||||
if (uploadedImage && uploadedImage.trim() !== '') {
|
||||
// Image was uploaded - use it
|
||||
recipeData.images = [{
|
||||
mediapath: uploadedImage,
|
||||
alt: '',
|
||||
caption: ''
|
||||
}];
|
||||
const recipeImage = formData.get('recipe_image') as File | null;
|
||||
if (recipeImage && recipeImage.size > 0) {
|
||||
try {
|
||||
// Process and save the image
|
||||
const { filename } = await processAndSaveRecipeImage(
|
||||
recipeImage,
|
||||
recipeData.short_name,
|
||||
IMAGE_DIR
|
||||
);
|
||||
|
||||
recipeData.images = [{
|
||||
mediapath: filename,
|
||||
alt: '',
|
||||
caption: ''
|
||||
}];
|
||||
} catch (imageError: any) {
|
||||
console.error('Image processing error:', imageError);
|
||||
return fail(400, {
|
||||
error: `Failed to process image: ${imageError.message}`,
|
||||
errors: ['Image processing failed'],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No image uploaded - use placeholder based on short_name
|
||||
recipeData.images = [{
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
let preamble = $state("");
|
||||
let addendum = $state("");
|
||||
let image_preview_url = $state("");
|
||||
let uploaded_image_filename = $state("");
|
||||
let selected_image_file = $state<File | null>(null);
|
||||
|
||||
// Translation workflow state
|
||||
let showTranslationWorkflow = $state(false);
|
||||
@@ -91,7 +91,7 @@
|
||||
let germanRecipeData = $derived({
|
||||
...card_data,
|
||||
...add_info,
|
||||
images: uploaded_image_filename ? [{ mediapath: uploaded_image_filename, alt: "", caption: "" }] : [],
|
||||
images: selected_image_file ? [{ mediapath: 'pending', alt: "", caption: "" }] : [],
|
||||
season: season_local,
|
||||
short_name: short_name.trim(),
|
||||
portions: portions_local,
|
||||
@@ -291,9 +291,14 @@ button.action_button {
|
||||
<form
|
||||
method="POST"
|
||||
bind:this={formElement}
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
return async ({ update, formData }) => {
|
||||
// Append the image file if one was selected
|
||||
if (selected_image_file) {
|
||||
formData.append('recipe_image', selected_image_file);
|
||||
}
|
||||
await update();
|
||||
submitting = false;
|
||||
};
|
||||
@@ -305,7 +310,6 @@ button.action_button {
|
||||
<input type="hidden" name="add_info_json" value={JSON.stringify(add_info)} />
|
||||
<input type="hidden" name="season" value={JSON.stringify(season_local)} />
|
||||
<input type="hidden" name="tags" value={JSON.stringify(card_data.tags)} />
|
||||
<input type="hidden" name="uploaded_image_filename" value={uploaded_image_filename} />
|
||||
|
||||
<!-- Translation data (added after approval) -->
|
||||
{#if translationData}
|
||||
@@ -319,7 +323,7 @@ button.action_button {
|
||||
<CardAdd
|
||||
bind:card_data
|
||||
bind:image_preview_url
|
||||
bind:uploaded_image_filename
|
||||
bind:selected_image_file
|
||||
short_name={short_name}
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IMAGE_DIR } from '$env/static/private';
|
||||
import { rename, access, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { constants } from 'fs';
|
||||
import { processAndSaveRecipeImage } from '$utils/imageProcessing';
|
||||
import {
|
||||
extractRecipeFromFormData,
|
||||
validateRecipeData,
|
||||
@@ -111,21 +112,37 @@ export const actions = {
|
||||
}
|
||||
|
||||
// Handle image scenarios
|
||||
const uploadedImage = formData.get('uploaded_image_filename')?.toString();
|
||||
const recipeImage = formData.get('recipe_image') as File | null;
|
||||
const keepExistingImage = formData.get('keep_existing_image') === 'true';
|
||||
const existingImagePath = formData.get('existing_image_path')?.toString();
|
||||
|
||||
if (uploadedImage) {
|
||||
// New image uploaded - delete old image files and use new one
|
||||
if (existingImagePath && existingImagePath !== uploadedImage) {
|
||||
await deleteRecipeImage(existingImagePath);
|
||||
}
|
||||
if (recipeImage && recipeImage.size > 0) {
|
||||
try {
|
||||
// Process and save the new image
|
||||
const { filename } = await processAndSaveRecipeImage(
|
||||
recipeImage,
|
||||
recipeData.short_name,
|
||||
IMAGE_DIR
|
||||
);
|
||||
|
||||
recipeData.images = [{
|
||||
mediapath: uploadedImage,
|
||||
alt: existingImagePath ? (recipeData.images?.[0]?.alt || '') : '',
|
||||
caption: existingImagePath ? (recipeData.images?.[0]?.caption || '') : ''
|
||||
}];
|
||||
// Delete old image files
|
||||
if (existingImagePath && existingImagePath !== filename) {
|
||||
await deleteRecipeImage(existingImagePath);
|
||||
}
|
||||
|
||||
recipeData.images = [{
|
||||
mediapath: filename,
|
||||
alt: existingImagePath ? (recipeData.images?.[0]?.alt || '') : '',
|
||||
caption: existingImagePath ? (recipeData.images?.[0]?.caption || '') : ''
|
||||
}];
|
||||
} catch (imageError: any) {
|
||||
console.error('Image processing error:', imageError);
|
||||
return fail(400, {
|
||||
error: `Failed to process image: ${imageError.message}`,
|
||||
errors: ['Image processing failed'],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
} else if (keepExistingImage && existingImagePath) {
|
||||
// Keep existing image
|
||||
recipeData.images = [{
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"https://bocken.org/static/rezepte/thumb/" +
|
||||
(data.recipe.images?.[0]?.mediapath || `${data.recipe.short_name}.webp`)
|
||||
);
|
||||
let uploaded_image_filename = $state("");
|
||||
let selected_image_file = $state<File | null>(null);
|
||||
|
||||
// Translation workflow state
|
||||
let showTranslationWorkflow = $state(false);
|
||||
@@ -109,10 +109,10 @@
|
||||
let currentRecipeData = $derived.by(() => {
|
||||
// Ensure we always have a valid images array with at least one item
|
||||
let recipeImages;
|
||||
if (uploaded_image_filename) {
|
||||
// New image uploaded
|
||||
if (selected_image_file) {
|
||||
// New image selected (will be uploaded on form submission)
|
||||
recipeImages = [{
|
||||
mediapath: uploaded_image_filename,
|
||||
mediapath: 'pending',
|
||||
alt: images[0]?.alt || "",
|
||||
caption: images[0]?.caption || ""
|
||||
}];
|
||||
@@ -371,9 +371,14 @@
|
||||
<form
|
||||
method="POST"
|
||||
bind:this={formElement}
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
return async ({ update, formData }) => {
|
||||
// Append the image file if one was selected
|
||||
if (selected_image_file) {
|
||||
formData.append('recipe_image', selected_image_file);
|
||||
}
|
||||
await update();
|
||||
submitting = false;
|
||||
};
|
||||
@@ -381,7 +386,7 @@
|
||||
>
|
||||
<!-- Hidden inputs for tracking -->
|
||||
<input type="hidden" name="original_short_name" value={old_short_name} />
|
||||
<input type="hidden" name="keep_existing_image" value={uploaded_image_filename ? "false" : "true"} />
|
||||
<input type="hidden" name="keep_existing_image" value={selected_image_file ? "false" : "true"} />
|
||||
<input type="hidden" name="existing_image_path" value={images[0]?.mediapath || `${old_short_name}.webp`} />
|
||||
|
||||
<!-- Hidden inputs for complex nested data -->
|
||||
@@ -390,7 +395,6 @@
|
||||
<input type="hidden" name="add_info_json" value={JSON.stringify(add_info)} />
|
||||
<input type="hidden" name="season" value={JSON.stringify(season_local)} />
|
||||
<input type="hidden" name="tags" value={JSON.stringify(card_data.tags)} />
|
||||
<input type="hidden" name="uploaded_image_filename" value={uploaded_image_filename} />
|
||||
<input type="hidden" name="datecreated" value={datecreated?.toString()} />
|
||||
|
||||
<!-- Translation data (updated after approval or marked needs_update) -->
|
||||
@@ -405,7 +409,7 @@
|
||||
<CardAdd
|
||||
bind:card_data
|
||||
bind:image_preview_url
|
||||
bind:uploaded_image_filename
|
||||
bind:selected_image_file
|
||||
short_name={short_name}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user