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:
@@ -8,12 +8,12 @@ import { onMount } from 'svelte'
|
|||||||
let {
|
let {
|
||||||
card_data = $bindable(),
|
card_data = $bindable(),
|
||||||
image_preview_url = $bindable(),
|
image_preview_url = $bindable(),
|
||||||
uploaded_image_filename = $bindable(''),
|
selected_image_file = $bindable(null),
|
||||||
short_name = ''
|
short_name = ''
|
||||||
} = $props<{
|
} = $props<{
|
||||||
card_data: any,
|
card_data: any,
|
||||||
image_preview_url: string,
|
image_preview_url: string,
|
||||||
uploaded_image_filename?: string,
|
selected_image_file?: File | null,
|
||||||
short_name: string
|
short_name: string
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -52,12 +52,11 @@ if(!card_data.tags){
|
|||||||
|
|
||||||
//locals
|
//locals
|
||||||
let new_tag = $state("");
|
let new_tag = $state("");
|
||||||
let uploading = $state(false);
|
|
||||||
let upload_error = $state("");
|
let upload_error = $state("");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles image file selection and upload
|
* Handles image file selection and preview
|
||||||
* Now uses FormData instead of base64 encoding for better security and performance
|
* The actual upload will happen when the form is submitted
|
||||||
*/
|
*/
|
||||||
export async function show_local_image(){
|
export async function show_local_image(){
|
||||||
const file = this.files[0];
|
const file = this.files[0];
|
||||||
@@ -81,57 +80,15 @@ export async function show_local_image(){
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show preview immediately
|
// Show preview and store file for later upload
|
||||||
image_preview_url = URL.createObjectURL(file);
|
image_preview_url = URL.createObjectURL(file);
|
||||||
|
selected_image_file = file;
|
||||||
upload_error = "";
|
upload_error = "";
|
||||||
|
|
||||||
// Upload to server
|
|
||||||
try {
|
|
||||||
uploading = true;
|
|
||||||
|
|
||||||
// Validate short_name is provided
|
|
||||||
if (!short_name || short_name.trim() === '') {
|
|
||||||
upload_error = 'Please provide a short name (URL) before uploading an image.';
|
|
||||||
alert(upload_error);
|
|
||||||
uploading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create FormData for upload
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image', file);
|
|
||||||
formData.append('name', short_name.trim());
|
|
||||||
|
|
||||||
const response = await fetch('/api/rezepte/img/add', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error_data = await response.json();
|
|
||||||
throw new Error(error_data.message || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
uploaded_image_filename = result.filename;
|
|
||||||
upload_error = "";
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Image upload error:', error);
|
|
||||||
upload_error = error.message || 'Failed to upload image. Please try again.';
|
|
||||||
alert(`Upload failed: ${upload_error}`);
|
|
||||||
|
|
||||||
// Clear preview on error
|
|
||||||
image_preview_url = "";
|
|
||||||
uploaded_image_filename = "";
|
|
||||||
} finally {
|
|
||||||
uploading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function remove_selected_images(){
|
export function remove_selected_images(){
|
||||||
image_preview_url = "";
|
image_preview_url = "";
|
||||||
uploaded_image_filename = "";
|
selected_image_file = null;
|
||||||
upload_error = "";
|
upload_error = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,11 +387,6 @@ input::placeholder{
|
|||||||
.tag_input{
|
.tag_input{
|
||||||
width: 12ch;
|
width: 12ch;
|
||||||
}
|
}
|
||||||
.upload-spinner {
|
|
||||||
color: white;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -453,15 +405,11 @@ input::placeholder{
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
<label class=img_label for=img_picker style={uploading ? 'opacity: 0.5; cursor: not-allowed;' : ''}>
|
<label class=img_label for=img_picker>
|
||||||
{#if uploading}
|
|
||||||
<div class="upload-spinner">Uploading...</div>
|
|
||||||
{:else}
|
|
||||||
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
|
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
|
||||||
{/if}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id=img_picker accept="image/webp,image/jpeg,image/jpg,image/png" onchange={show_local_image} disabled={uploading}>
|
<input type="file" id=img_picker accept="image/webp,image/jpeg,image/jpg,image/png" onchange={show_local_image}>
|
||||||
<div class=title>
|
<div class=title>
|
||||||
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
|
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Actions, PageServerLoad } from './$types';
|
|||||||
import { Recipe } from '$models/Recipe';
|
import { Recipe } from '$models/Recipe';
|
||||||
import { dbConnect } from '$utils/db';
|
import { dbConnect } from '$utils/db';
|
||||||
import { invalidateRecipeCaches } from '$lib/server/cache';
|
import { invalidateRecipeCaches } from '$lib/server/cache';
|
||||||
|
import { IMAGE_DIR } from '$env/static/private';
|
||||||
|
import { processAndSaveRecipeImage } from '$utils/imageProcessing';
|
||||||
import {
|
import {
|
||||||
extractRecipeFromFormData,
|
extractRecipeFromFormData,
|
||||||
validateRecipeData,
|
validateRecipeData,
|
||||||
@@ -49,14 +51,29 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle optional image upload
|
// Handle optional image upload
|
||||||
const uploadedImage = formData.get('uploaded_image_filename')?.toString();
|
const recipeImage = formData.get('recipe_image') as File | null;
|
||||||
if (uploadedImage && uploadedImage.trim() !== '') {
|
if (recipeImage && recipeImage.size > 0) {
|
||||||
// Image was uploaded - use it
|
try {
|
||||||
|
// Process and save the image
|
||||||
|
const { filename } = await processAndSaveRecipeImage(
|
||||||
|
recipeImage,
|
||||||
|
recipeData.short_name,
|
||||||
|
IMAGE_DIR
|
||||||
|
);
|
||||||
|
|
||||||
recipeData.images = [{
|
recipeData.images = [{
|
||||||
mediapath: uploadedImage,
|
mediapath: filename,
|
||||||
alt: '',
|
alt: '',
|
||||||
caption: ''
|
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 {
|
} else {
|
||||||
// No image uploaded - use placeholder based on short_name
|
// No image uploaded - use placeholder based on short_name
|
||||||
recipeData.images = [{
|
recipeData.images = [{
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
let preamble = $state("");
|
let preamble = $state("");
|
||||||
let addendum = $state("");
|
let addendum = $state("");
|
||||||
let image_preview_url = $state("");
|
let image_preview_url = $state("");
|
||||||
let uploaded_image_filename = $state("");
|
let selected_image_file = $state<File | null>(null);
|
||||||
|
|
||||||
// Translation workflow state
|
// Translation workflow state
|
||||||
let showTranslationWorkflow = $state(false);
|
let showTranslationWorkflow = $state(false);
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
let germanRecipeData = $derived({
|
let germanRecipeData = $derived({
|
||||||
...card_data,
|
...card_data,
|
||||||
...add_info,
|
...add_info,
|
||||||
images: uploaded_image_filename ? [{ mediapath: uploaded_image_filename, alt: "", caption: "" }] : [],
|
images: selected_image_file ? [{ mediapath: 'pending', alt: "", caption: "" }] : [],
|
||||||
season: season_local,
|
season: season_local,
|
||||||
short_name: short_name.trim(),
|
short_name: short_name.trim(),
|
||||||
portions: portions_local,
|
portions: portions_local,
|
||||||
@@ -291,9 +291,14 @@ button.action_button {
|
|||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
bind:this={formElement}
|
bind:this={formElement}
|
||||||
|
enctype="multipart/form-data"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
submitting = true;
|
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();
|
await update();
|
||||||
submitting = false;
|
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="add_info_json" value={JSON.stringify(add_info)} />
|
||||||
<input type="hidden" name="season" value={JSON.stringify(season_local)} />
|
<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="tags" value={JSON.stringify(card_data.tags)} />
|
||||||
<input type="hidden" name="uploaded_image_filename" value={uploaded_image_filename} />
|
|
||||||
|
|
||||||
<!-- Translation data (added after approval) -->
|
<!-- Translation data (added after approval) -->
|
||||||
{#if translationData}
|
{#if translationData}
|
||||||
@@ -319,7 +323,7 @@ button.action_button {
|
|||||||
<CardAdd
|
<CardAdd
|
||||||
bind:card_data
|
bind:card_data
|
||||||
bind:image_preview_url
|
bind:image_preview_url
|
||||||
bind:uploaded_image_filename
|
bind:selected_image_file
|
||||||
short_name={short_name}
|
short_name={short_name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { IMAGE_DIR } from '$env/static/private';
|
|||||||
import { rename, access, unlink } from 'fs/promises';
|
import { rename, access, unlink } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { constants } from 'fs';
|
import { constants } from 'fs';
|
||||||
|
import { processAndSaveRecipeImage } from '$utils/imageProcessing';
|
||||||
import {
|
import {
|
||||||
extractRecipeFromFormData,
|
extractRecipeFromFormData,
|
||||||
validateRecipeData,
|
validateRecipeData,
|
||||||
@@ -111,21 +112,37 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle image scenarios
|
// 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 keepExistingImage = formData.get('keep_existing_image') === 'true';
|
||||||
const existingImagePath = formData.get('existing_image_path')?.toString();
|
const existingImagePath = formData.get('existing_image_path')?.toString();
|
||||||
|
|
||||||
if (uploadedImage) {
|
if (recipeImage && recipeImage.size > 0) {
|
||||||
// New image uploaded - delete old image files and use new one
|
try {
|
||||||
if (existingImagePath && existingImagePath !== uploadedImage) {
|
// Process and save the new image
|
||||||
|
const { filename } = await processAndSaveRecipeImage(
|
||||||
|
recipeImage,
|
||||||
|
recipeData.short_name,
|
||||||
|
IMAGE_DIR
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete old image files
|
||||||
|
if (existingImagePath && existingImagePath !== filename) {
|
||||||
await deleteRecipeImage(existingImagePath);
|
await deleteRecipeImage(existingImagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
recipeData.images = [{
|
recipeData.images = [{
|
||||||
mediapath: uploadedImage,
|
mediapath: filename,
|
||||||
alt: existingImagePath ? (recipeData.images?.[0]?.alt || '') : '',
|
alt: existingImagePath ? (recipeData.images?.[0]?.alt || '') : '',
|
||||||
caption: existingImagePath ? (recipeData.images?.[0]?.caption || '') : ''
|
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) {
|
} else if (keepExistingImage && existingImagePath) {
|
||||||
// Keep existing image
|
// Keep existing image
|
||||||
recipeData.images = [{
|
recipeData.images = [{
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"https://bocken.org/static/rezepte/thumb/" +
|
"https://bocken.org/static/rezepte/thumb/" +
|
||||||
(data.recipe.images?.[0]?.mediapath || `${data.recipe.short_name}.webp`)
|
(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
|
// Translation workflow state
|
||||||
let showTranslationWorkflow = $state(false);
|
let showTranslationWorkflow = $state(false);
|
||||||
@@ -109,10 +109,10 @@
|
|||||||
let currentRecipeData = $derived.by(() => {
|
let currentRecipeData = $derived.by(() => {
|
||||||
// Ensure we always have a valid images array with at least one item
|
// Ensure we always have a valid images array with at least one item
|
||||||
let recipeImages;
|
let recipeImages;
|
||||||
if (uploaded_image_filename) {
|
if (selected_image_file) {
|
||||||
// New image uploaded
|
// New image selected (will be uploaded on form submission)
|
||||||
recipeImages = [{
|
recipeImages = [{
|
||||||
mediapath: uploaded_image_filename,
|
mediapath: 'pending',
|
||||||
alt: images[0]?.alt || "",
|
alt: images[0]?.alt || "",
|
||||||
caption: images[0]?.caption || ""
|
caption: images[0]?.caption || ""
|
||||||
}];
|
}];
|
||||||
@@ -371,9 +371,14 @@
|
|||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
bind:this={formElement}
|
bind:this={formElement}
|
||||||
|
enctype="multipart/form-data"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
submitting = true;
|
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();
|
await update();
|
||||||
submitting = false;
|
submitting = false;
|
||||||
};
|
};
|
||||||
@@ -381,7 +386,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Hidden inputs for tracking -->
|
<!-- Hidden inputs for tracking -->
|
||||||
<input type="hidden" name="original_short_name" value={old_short_name} />
|
<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`} />
|
<input type="hidden" name="existing_image_path" value={images[0]?.mediapath || `${old_short_name}.webp`} />
|
||||||
|
|
||||||
<!-- Hidden inputs for complex nested data -->
|
<!-- 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="add_info_json" value={JSON.stringify(add_info)} />
|
||||||
<input type="hidden" name="season" value={JSON.stringify(season_local)} />
|
<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="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()} />
|
<input type="hidden" name="datecreated" value={datecreated?.toString()} />
|
||||||
|
|
||||||
<!-- Translation data (updated after approval or marked needs_update) -->
|
<!-- Translation data (updated after approval or marked needs_update) -->
|
||||||
@@ -405,7 +409,7 @@
|
|||||||
<CardAdd
|
<CardAdd
|
||||||
bind:card_data
|
bind:card_data
|
||||||
bind:image_preview_url
|
bind:image_preview_url
|
||||||
bind:uploaded_image_filename
|
bind:selected_image_file
|
||||||
short_name={short_name}
|
short_name={short_name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
79
src/utils/imageProcessing.ts
Normal file
79
src/utils/imageProcessing.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
||||||
|
import { validateImageFile } from '$utils/imageValidation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process and save recipe image with multiple versions (full, thumb, placeholder)
|
||||||
|
* @param file - The image File object
|
||||||
|
* @param name - The base name for the image (usually recipe short_name)
|
||||||
|
* @param imageDir - The base directory where images are stored
|
||||||
|
* @returns Object with hashedFilename and unhashedFilename
|
||||||
|
*/
|
||||||
|
export async function processAndSaveRecipeImage(
|
||||||
|
file: File,
|
||||||
|
name: string,
|
||||||
|
imageDir: string
|
||||||
|
): Promise<{ filename: string; unhashedFilename: string }> {
|
||||||
|
// Comprehensive security validation
|
||||||
|
const validationResult = await validateImageFile(file);
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
throw new Error(validationResult.error || 'Invalid image file');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert File to Buffer for processing
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// Generate content hash for cache busting
|
||||||
|
const imageHash = generateImageHashFromBuffer(buffer);
|
||||||
|
const hashedFilename = getHashedFilename(name, imageHash);
|
||||||
|
const unhashedFilename = name + '.webp';
|
||||||
|
|
||||||
|
// Process image with Sharp - convert to WebP format
|
||||||
|
// Save full size - both hashed and unhashed versions
|
||||||
|
const fullBuffer = await sharp(buffer)
|
||||||
|
.toFormat('webp')
|
||||||
|
.webp({ quality: 90 }) // High quality for full size
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
await sharp(fullBuffer).toFile(
|
||||||
|
path.join(imageDir, 'rezepte', 'full', hashedFilename)
|
||||||
|
);
|
||||||
|
await sharp(fullBuffer).toFile(
|
||||||
|
path.join(imageDir, 'rezepte', 'full', unhashedFilename)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save thumbnail (800px width) - both hashed and unhashed versions
|
||||||
|
const thumbBuffer = await sharp(buffer)
|
||||||
|
.resize({ width: 800 })
|
||||||
|
.toFormat('webp')
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
await sharp(thumbBuffer).toFile(
|
||||||
|
path.join(imageDir, 'rezepte', 'thumb', hashedFilename)
|
||||||
|
);
|
||||||
|
await sharp(thumbBuffer).toFile(
|
||||||
|
path.join(imageDir, 'rezepte', 'thumb', unhashedFilename)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save placeholder (20px width) - both hashed and unhashed versions
|
||||||
|
const placeholderBuffer = await sharp(buffer)
|
||||||
|
.resize({ width: 20 })
|
||||||
|
.toFormat('webp')
|
||||||
|
.webp({ quality: 60 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
await sharp(placeholderBuffer).toFile(
|
||||||
|
path.join(imageDir, 'rezepte', 'placeholder', hashedFilename)
|
||||||
|
);
|
||||||
|
await sharp(placeholderBuffer).toFile(
|
||||||
|
path.join(imageDir, 'rezepte', 'placeholder', unhashedFilename)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: hashedFilename,
|
||||||
|
unhashedFilename: unhashedFilename
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user