refactor: migrate recipe forms to SvelteKit actions with secure image upload
Refactor recipe add/edit routes from client-side fetch to proper SvelteKit form actions with progressive enhancement and comprehensive security improvements. **Security Enhancements:** - Implement 5-layer image validation (file size, MIME type, extension, magic bytes, Sharp structure) - Replace insecure base64 JSON encoding with FormData for file uploads - Add file-type@19 dependency for magic bytes validation - Validate actual file type via magic bytes to prevent file type spoofing **Progressive Enhancement:** - Forms now work without JavaScript using native browser submission - Add use:enhance for improved client-side UX when JS is available - Serialize complex nested data (ingredients/instructions) via JSON in hidden fields - Translation workflow integrated via programmatic form submission **Bug Fixes:** - Add type="button" to all interactive buttons in CreateIngredientList and CreateStepList to prevent premature form submission when clicking on ingredients/steps - Fix SSR errors by using season_local state instead of get_season() DOM query - Fix redirect handling in form actions (redirects were being caught as errors) - Fix TranslationApproval to handle recipes without images using null-safe checks - Add reactive effect to sync editableEnglish.images with germanData.images length - Detect and hide 150x150 placeholder images in CardAdd component **Features:** - Make image uploads optional for recipe creation (use placeholder based on short_name) - Handle three image scenarios in edit: keep existing, upload new, rename on short_name change - Automatic image file renaming across full/thumb/placeholder directories when short_name changes - Change detection for partial translation updates in edit mode **Technical Changes:** - Create imageValidation.ts utility with comprehensive file validation - Create recipeFormHelpers.ts for data extraction, validation, and serialization - Refactor /api/rezepte/img/add endpoint to use FormData instead of base64 - Update CardAdd component to upload via FormData immediately with proper error handling - Use Image API for placeholder detection (avoids CORS issues with fetch)
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { redirect, fail } from "@sveltejs/kit";
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { Recipe } from '$models/Recipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { invalidateRecipeCaches } from '$lib/server/cache';
|
||||
import {
|
||||
extractRecipeFromFormData,
|
||||
validateRecipeData,
|
||||
serializeRecipeForDatabase
|
||||
} from '$utils/recipeFormHelpers';
|
||||
|
||||
export async function load({locals, params}) {
|
||||
export const load: PageServerLoad = async ({locals, params}) => {
|
||||
// Add is German-only - redirect to German version
|
||||
if (params.recipeLang === 'recipes') {
|
||||
throw redirect(301, '/rezepte/add');
|
||||
@@ -8,6 +17,105 @@ export async function load({locals, params}) {
|
||||
|
||||
const session = await locals.auth();
|
||||
return {
|
||||
user: session?.user
|
||||
user: session?.user
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, locals, params }) => {
|
||||
// Check authentication
|
||||
const auth = await locals.auth();
|
||||
if (!auth) {
|
||||
return fail(401, {
|
||||
error: 'You must be logged in to add recipes',
|
||||
requiresAuth: true
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
// Extract recipe data from FormData
|
||||
const recipeData = extractRecipeFromFormData(formData);
|
||||
|
||||
// Validate required fields
|
||||
const validationErrors = validateRecipeData(recipeData);
|
||||
if (validationErrors.length > 0) {
|
||||
return fail(400, {
|
||||
error: validationErrors.join(', '),
|
||||
errors: validationErrors,
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
|
||||
// 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: ''
|
||||
}];
|
||||
} else {
|
||||
// No image uploaded - use placeholder based on short_name
|
||||
recipeData.images = [{
|
||||
mediapath: `${recipeData.short_name}.webp`,
|
||||
alt: '',
|
||||
caption: ''
|
||||
}];
|
||||
}
|
||||
|
||||
// Serialize for database
|
||||
const recipe_json = serializeRecipeForDatabase(recipeData);
|
||||
|
||||
// Connect to database and create recipe
|
||||
await dbConnect();
|
||||
|
||||
try {
|
||||
await Recipe.create(recipe_json);
|
||||
|
||||
// Invalidate recipe caches after successful creation
|
||||
await invalidateRecipeCaches();
|
||||
|
||||
// Redirect to the new recipe page
|
||||
throw redirect(303, `/${params.recipeLang}/${recipeData.short_name}`);
|
||||
} catch (dbError: any) {
|
||||
// Re-throw redirects (they're not errors)
|
||||
if (dbError?.status >= 300 && dbError?.status < 400) {
|
||||
throw dbError;
|
||||
}
|
||||
|
||||
console.error('Database error creating recipe:', dbError);
|
||||
|
||||
// Check for duplicate key error
|
||||
if (dbError.code === 11000) {
|
||||
return fail(400, {
|
||||
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
|
||||
errors: ['Duplicate short_name'],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
|
||||
return fail(500, {
|
||||
error: `Failed to create recipe: ${dbError.message || 'Unknown database error'}`,
|
||||
errors: [dbError.message],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Re-throw redirects (they're not errors)
|
||||
if (error?.status >= 300 && error?.status < 400) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Error processing recipe submission:', error);
|
||||
|
||||
return fail(500, {
|
||||
error: `Failed to process recipe: ${error.message || 'Unknown error'}`,
|
||||
errors: [error.message],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
@@ -1,44 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import Check from '$lib/assets/icons/Check.svelte';
|
||||
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
||||
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
||||
import '$lib/css/action_button.css'
|
||||
import '$lib/css/nordtheme.css'
|
||||
import CardAdd from '$lib/components/CardAdd.svelte';
|
||||
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
|
||||
import CreateStepList from '$lib/components/CreateStepList.svelte';
|
||||
import '$lib/css/action_button.css';
|
||||
import '$lib/css/nordtheme.css';
|
||||
|
||||
let preamble = ""
|
||||
let addendum = ""
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// Recipe data state
|
||||
let preamble = $state("");
|
||||
let addendum = $state("");
|
||||
let image_preview_url = $state("");
|
||||
let uploaded_image_filename = $state("");
|
||||
|
||||
// Translation workflow state
|
||||
let showTranslationWorkflow = false;
|
||||
let translationData: any = null;
|
||||
let showTranslationWorkflow = $state(false);
|
||||
let translationData: any = $state(null);
|
||||
|
||||
// Season store
|
||||
import { season } from '$lib/js/season_store';
|
||||
import { portions } from '$lib/js/portions_store';
|
||||
import { img } from '$lib/js/img_store';
|
||||
season.update(() => [])
|
||||
let season_local
|
||||
|
||||
season.update(() => []);
|
||||
let season_local = $state<number[]>([]);
|
||||
season.subscribe((s) => {
|
||||
season_local = s
|
||||
season_local = s;
|
||||
});
|
||||
let portions_local
|
||||
portions.update(() => "")
|
||||
|
||||
let portions_local = $state("");
|
||||
portions.update(() => "");
|
||||
portions.subscribe((p) => {
|
||||
portions_local = p});
|
||||
let img_local
|
||||
img.update(() => "")
|
||||
img.subscribe((i) => {
|
||||
img_local = i});
|
||||
portions_local = p;
|
||||
});
|
||||
|
||||
|
||||
|
||||
export let card_data ={
|
||||
let card_data = $state({
|
||||
icon: "",
|
||||
category: "",
|
||||
name: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
}
|
||||
export let add_info ={
|
||||
tags: [] as string[],
|
||||
});
|
||||
|
||||
let add_info = $state({
|
||||
preparation: "",
|
||||
fermentation: {
|
||||
bulk: "",
|
||||
@@ -51,70 +59,43 @@
|
||||
},
|
||||
total_time: "",
|
||||
cooking: "",
|
||||
}
|
||||
});
|
||||
|
||||
let images = []
|
||||
let short_name = ""
|
||||
let datecreated = new Date()
|
||||
let datemodified = datecreated
|
||||
let isBaseRecipe = false
|
||||
let short_name = $state("");
|
||||
let isBaseRecipe = $state(false);
|
||||
let ingredients = $state<any[]>([]);
|
||||
let instructions = $state<any[]>([]);
|
||||
|
||||
import type { PageData } from './$types';
|
||||
import CardAdd from '$lib/components/CardAdd.svelte';
|
||||
// Form submission state
|
||||
let submitting = $state(false);
|
||||
let formElement: HTMLFormElement;
|
||||
|
||||
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
|
||||
export let ingredients = []
|
||||
|
||||
import CreateStepList from '$lib/components/CreateStepList.svelte';
|
||||
export let instructions = []
|
||||
|
||||
|
||||
function get_season(){
|
||||
let season = []
|
||||
// Get season data from checkboxes
|
||||
function get_season(): number[] {
|
||||
const season: number[] = [];
|
||||
const el = document.getElementById("labels");
|
||||
for(var i = 0; i < el.children.length; i++){
|
||||
if(el.children[i].children[0].children[0].checked){
|
||||
season.push(i+1)
|
||||
if (!el) return season;
|
||||
|
||||
for (let i = 0; i < el.children.length; i++) {
|
||||
const checkbox = el.children[i].children[0].children[0] as HTMLInputElement;
|
||||
if (checkbox?.checked) {
|
||||
season.push(i + 1);
|
||||
}
|
||||
}
|
||||
return season
|
||||
}
|
||||
function write_season(season){
|
||||
const el = document.getElementById("labels");
|
||||
for(var i = 0; i < season.length; i++){
|
||||
el.children[i].children[0].children[0].checked = true
|
||||
}
|
||||
return season;
|
||||
}
|
||||
|
||||
async function upload_img(){
|
||||
console.log("uploading...")
|
||||
console.log(img_local)
|
||||
const data = {
|
||||
image: img_local,
|
||||
name: short_name.trim(),
|
||||
}
|
||||
await fetch(`/api/rezepte/img/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
credentials: 'include',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare the German recipe data
|
||||
// Prepare German recipe data
|
||||
function getGermanRecipeData() {
|
||||
return {
|
||||
...card_data,
|
||||
...add_info,
|
||||
images: [{mediapath: short_name.trim() + '.webp', alt: "", caption: ""}],
|
||||
images: uploaded_image_filename ? [{ mediapath: uploaded_image_filename, alt: "", caption: "" }] : [],
|
||||
season: season_local,
|
||||
short_name : short_name.trim(),
|
||||
short_name: short_name.trim(),
|
||||
portions: portions_local,
|
||||
datecreated,
|
||||
datemodified,
|
||||
datecreated: new Date(),
|
||||
datemodified: new Date(),
|
||||
instructions,
|
||||
ingredients,
|
||||
preamble,
|
||||
@@ -125,7 +106,7 @@
|
||||
|
||||
// Show translation workflow before submission
|
||||
function prepareSubmit() {
|
||||
// Validate required fields
|
||||
// Client-side validation
|
||||
if (!short_name.trim()) {
|
||||
alert('Bitte geben Sie einen Kurznamen ein');
|
||||
return;
|
||||
@@ -142,16 +123,24 @@
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Handle translation approval
|
||||
// Handle translation approval - populate form and submit
|
||||
function handleTranslationApproved(event: CustomEvent) {
|
||||
translationData = event.detail.translatedRecipe;
|
||||
doPost();
|
||||
|
||||
// Submit the form programmatically
|
||||
if (formElement) {
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle translation skipped
|
||||
// Handle translation skipped - submit without translation
|
||||
function handleTranslationSkipped() {
|
||||
translationData = null;
|
||||
doPost();
|
||||
|
||||
// Submit the form programmatically
|
||||
if (formElement) {
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle translation cancelled
|
||||
@@ -160,51 +149,16 @@
|
||||
translationData = null;
|
||||
}
|
||||
|
||||
// Actually submit the recipe
|
||||
async function doPost () {
|
||||
upload_img()
|
||||
console.log(add_info.total_time)
|
||||
|
||||
const recipeData = getGermanRecipeData();
|
||||
|
||||
// Add translations if available
|
||||
if (translationData) {
|
||||
recipeData.translations = {
|
||||
en: translationData
|
||||
};
|
||||
recipeData.translationMetadata = {
|
||||
lastModifiedGerman: new Date(),
|
||||
fieldsModifiedSinceTranslation: [],
|
||||
};
|
||||
// Display form errors if any
|
||||
$effect(() => {
|
||||
if (form?.error) {
|
||||
alert(`Fehler: ${form.error}`);
|
||||
}
|
||||
|
||||
const res = await fetch('/api/rezepte/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipe: recipeData,
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if(res.status === 200){
|
||||
const url = location.href.split('/')
|
||||
url.splice(url.length -1, 1);
|
||||
url.push(short_name)
|
||||
location.assign(url.join('/'))
|
||||
}
|
||||
else{
|
||||
const item = await res.json();
|
||||
alert(item.message)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
input{
|
||||
input {
|
||||
display: block;
|
||||
border: unset;
|
||||
margin: 1rem auto;
|
||||
@@ -213,14 +167,12 @@ input{
|
||||
background-color: var(--nord4);
|
||||
font-size: 1.1rem;
|
||||
transition: 100ms;
|
||||
|
||||
}
|
||||
input:hover,
|
||||
input:focus-visible
|
||||
{
|
||||
input:focus-visible {
|
||||
scale: 1.05 1.05;
|
||||
}
|
||||
.list_wrapper{
|
||||
.list_wrapper {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -228,22 +180,22 @@ input:focus-visible
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
@media screen and (max-width: 700px){
|
||||
.list_wrapper{
|
||||
@media screen and (max-width: 700px) {
|
||||
.list_wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
h1{
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.title_container{
|
||||
.title_container {
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.title{
|
||||
.title {
|
||||
position: relative;
|
||||
width: min(800px, 80vw);
|
||||
margin-block: 2rem;
|
||||
@@ -251,7 +203,7 @@ h1{
|
||||
background-color: var(--nord6);
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
.title p{
|
||||
.title p {
|
||||
border: 2px solid var(--nord1);
|
||||
border-radius: 10000px;
|
||||
padding: 0.5em 1em;
|
||||
@@ -259,10 +211,10 @@ h1{
|
||||
transition: 200ms;
|
||||
}
|
||||
.title p:hover,
|
||||
.title p:focus-within{
|
||||
.title p:focus-within {
|
||||
scale: 1.02 1.02;
|
||||
}
|
||||
.addendum{
|
||||
.addendum {
|
||||
font-size: 1.1rem;
|
||||
max-width: 90%;
|
||||
margin-inline: auto;
|
||||
@@ -272,23 +224,22 @@ h1{
|
||||
transition: 100ms;
|
||||
}
|
||||
.addendum:hover,
|
||||
.addendum:focus-within
|
||||
{
|
||||
.addendum:focus-within {
|
||||
scale: 1.02 1.02;
|
||||
}
|
||||
.addendum_wrapper{
|
||||
.addendum_wrapper {
|
||||
max-width: 1000px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
h3{
|
||||
h3 {
|
||||
text-align: center;
|
||||
}
|
||||
button.action_button{
|
||||
button.action_button {
|
||||
animation: unset !important;
|
||||
font-size: 1.3rem;
|
||||
color: white;
|
||||
}
|
||||
.submit_buttons{
|
||||
.submit_buttons {
|
||||
display: flex;
|
||||
margin-inline: auto;
|
||||
max-width: 1000px;
|
||||
@@ -297,17 +248,27 @@ button.action_button{
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.submit_buttons p{
|
||||
.submit_buttons p {
|
||||
padding: 0;
|
||||
padding-right: 0.5em;
|
||||
margin: 0;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
.title{
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.title {
|
||||
background-color: var(--nord6-dark);
|
||||
}
|
||||
}
|
||||
.error-message {
|
||||
background: var(--nord11);
|
||||
color: var(--nord6);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem auto;
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rezept erstellen</title>
|
||||
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
|
||||
@@ -315,57 +276,119 @@ button.action_button{
|
||||
|
||||
<h1>Rezept erstellen</h1>
|
||||
|
||||
<CardAdd {card_data}></CardAdd>
|
||||
|
||||
<h3>Kurzname (für URL):</h3>
|
||||
<input bind:value={short_name} placeholder="Kurzname"/>
|
||||
|
||||
<div style="text-align: center; margin: 1rem;">
|
||||
<label style="font-size: 1.1rem; cursor: pointer;">
|
||||
<input type="checkbox" bind:checked={isBaseRecipe} style="width: auto; display: inline; margin-right: 0.5em;" />
|
||||
Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class=title_container>
|
||||
<div class=title>
|
||||
<h4>Eine etwas längere Beschreibung:</h4>
|
||||
<p bind:innerText={preamble} contenteditable></p>
|
||||
<div class=tags>
|
||||
<h4>Saison:</h4>
|
||||
<SeasonSelect></SeasonSelect>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=list_wrapper>
|
||||
<div>
|
||||
<CreateIngredientList {ingredients}></CreateIngredientList>
|
||||
</div>
|
||||
<div>
|
||||
<CreateStepList {instructions} {add_info}></CreateStepList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=addendum_wrapper>
|
||||
<h3>Nachtrag:</h3>
|
||||
<div class=addendum bind:innerText={addendum} contenteditable></div>
|
||||
</div>
|
||||
|
||||
{#if !showTranslationWorkflow}
|
||||
<div class=submit_buttons>
|
||||
<button class=action_button onclick={prepareSubmit}><p>Weiter zur Übersetzung</p><Check fill=white width=2rem height=2rem></Check></button>
|
||||
</div>
|
||||
{#if form?.error}
|
||||
<div class="error-message">
|
||||
<strong>Fehler:</strong> {form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
bind:this={formElement}
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
submitting = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- Hidden inputs for complex nested data -->
|
||||
<input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} />
|
||||
<input type="hidden" name="instructions_json" value={JSON.stringify(instructions)} />
|
||||
<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}
|
||||
<input type="hidden" name="translation_json" value={JSON.stringify(translationData)} />
|
||||
<input type="hidden" name="translation_metadata_json" value={JSON.stringify({
|
||||
lastModifiedGerman: new Date(),
|
||||
fieldsModifiedSinceTranslation: []
|
||||
})} />
|
||||
{/if}
|
||||
|
||||
<CardAdd
|
||||
bind:card_data
|
||||
bind:image_preview_url
|
||||
bind:uploaded_image_filename
|
||||
short_name={short_name}
|
||||
/>
|
||||
|
||||
<h3>Kurzname (für URL):</h3>
|
||||
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
|
||||
|
||||
<!-- Hidden inputs for card data -->
|
||||
<input type="hidden" name="name" value={card_data.name} />
|
||||
<input type="hidden" name="description" value={card_data.description} />
|
||||
<input type="hidden" name="category" value={card_data.category} />
|
||||
<input type="hidden" name="icon" value={card_data.icon} />
|
||||
<input type="hidden" name="portions" value={portions_local} />
|
||||
|
||||
<div style="text-align: center; margin: 1rem;">
|
||||
<label style="font-size: 1.1rem; cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isBaseRecipe"
|
||||
bind:checked={isBaseRecipe}
|
||||
style="width: auto; display: inline; margin-right: 0.5em;"
|
||||
/>
|
||||
Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="title_container">
|
||||
<div class="title">
|
||||
<h4>Eine etwas längere Beschreibung:</h4>
|
||||
<p bind:innerText={preamble} contenteditable></p>
|
||||
<input type="hidden" name="preamble" value={preamble} />
|
||||
|
||||
<div class="tags">
|
||||
<h4>Saison:</h4>
|
||||
<SeasonSelect />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list_wrapper">
|
||||
<div>
|
||||
<CreateIngredientList bind:ingredients />
|
||||
</div>
|
||||
<div>
|
||||
<CreateStepList bind:instructions bind:add_info />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addendum_wrapper">
|
||||
<h3>Nachtrag:</h3>
|
||||
<div class="addendum" bind:innerText={addendum} contenteditable></div>
|
||||
<input type="hidden" name="addendum" value={addendum} />
|
||||
</div>
|
||||
|
||||
{#if !showTranslationWorkflow}
|
||||
<div class="submit_buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="action_button"
|
||||
onclick={prepareSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
<p>Weiter zur Übersetzung</p>
|
||||
<Check fill="white" width="2rem" height="2rem" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if showTranslationWorkflow}
|
||||
<div id="translation-section">
|
||||
<TranslationApproval
|
||||
germanData={getGermanRecipeData()}
|
||||
onapproved={handleTranslationApproved}
|
||||
onskipped={handleTranslationSkipped}
|
||||
oncancelled={handleTranslationCancelled}
|
||||
/>
|
||||
</div>
|
||||
<div id="translation-section">
|
||||
<TranslationApproval
|
||||
germanData={getGermanRecipeData()}
|
||||
onapproved={handleTranslationApproved}
|
||||
onskipped={handleTranslationSkipped}
|
||||
oncancelled={handleTranslationCancelled}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { PageServerLoad } from "./$types";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { Actions, PageServerLoad } from "./$types";
|
||||
import { redirect, fail } from "@sveltejs/kit";
|
||||
import { Recipe } from '$models/Recipe';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { invalidateRecipeCaches } from '$lib/server/cache';
|
||||
import { IMAGE_DIR } from '$env/static/private';
|
||||
import { rename, access } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { constants } from 'fs';
|
||||
import {
|
||||
extractRecipeFromFormData,
|
||||
validateRecipeData,
|
||||
serializeRecipeForDatabase,
|
||||
detectChangedFields
|
||||
} from '$utils/recipeFormHelpers';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params, locals}) => {
|
||||
// Edit is German-only - redirect to German version
|
||||
@@ -14,11 +27,169 @@ export const load: PageServerLoad = async ({ fetch, params, locals}) => {
|
||||
throw redirect(301, '/rezepte');
|
||||
}
|
||||
|
||||
let current_month = new Date().getMonth() + 1
|
||||
const apiRes = await fetch(`/api/rezepte/items/${params.name}`);
|
||||
const recipe = await apiRes.json();
|
||||
const session = await locals.auth();
|
||||
return {recipe: recipe,
|
||||
user: session?.user
|
||||
return {
|
||||
recipe: recipe,
|
||||
user: session?.user
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, locals, params }) => {
|
||||
// Check authentication
|
||||
const auth = await locals.auth();
|
||||
if (!auth) {
|
||||
return fail(401, {
|
||||
error: 'You must be logged in to edit recipes',
|
||||
requiresAuth: true
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
// Extract recipe data from FormData
|
||||
const recipeData = extractRecipeFromFormData(formData);
|
||||
|
||||
// Get original short_name for update query and image rename
|
||||
const originalShortName = formData.get('original_short_name')?.toString();
|
||||
if (!originalShortName) {
|
||||
return fail(400, {
|
||||
error: 'Original short name is required for edit',
|
||||
errors: ['Missing original_short_name'],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const validationErrors = validateRecipeData(recipeData);
|
||||
if (validationErrors.length > 0) {
|
||||
return fail(400, {
|
||||
error: validationErrors.join(', '),
|
||||
errors: validationErrors,
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
|
||||
// Handle image scenarios
|
||||
const uploadedImage = formData.get('uploaded_image_filename')?.toString();
|
||||
const keepExistingImage = formData.get('keep_existing_image') === 'true';
|
||||
const existingImagePath = formData.get('existing_image_path')?.toString();
|
||||
|
||||
if (uploadedImage) {
|
||||
// New image uploaded - use it
|
||||
recipeData.images = [{
|
||||
mediapath: uploadedImage,
|
||||
alt: existingImagePath ? (recipeData.images?.[0]?.alt || '') : '',
|
||||
caption: existingImagePath ? (recipeData.images?.[0]?.caption || '') : ''
|
||||
}];
|
||||
} else if (keepExistingImage && existingImagePath) {
|
||||
// Keep existing image
|
||||
recipeData.images = [{
|
||||
mediapath: existingImagePath,
|
||||
alt: recipeData.images?.[0]?.alt || '',
|
||||
caption: recipeData.images?.[0]?.caption || ''
|
||||
}];
|
||||
} else {
|
||||
// No image provided - use placeholder based on short_name
|
||||
recipeData.images = [{
|
||||
mediapath: `${recipeData.short_name}.webp`,
|
||||
alt: '',
|
||||
caption: ''
|
||||
}];
|
||||
}
|
||||
|
||||
// Handle short_name change (rename images)
|
||||
if (originalShortName !== recipeData.short_name) {
|
||||
const imageDirectories = ['full', 'thumb', 'placeholder'];
|
||||
|
||||
for (const dir of imageDirectories) {
|
||||
const oldPath = join(IMAGE_DIR, 'rezepte', dir, `${originalShortName}.webp`);
|
||||
const newPath = join(IMAGE_DIR, 'rezepte', dir, `${recipeData.short_name}.webp`);
|
||||
|
||||
try {
|
||||
// Check if old file exists
|
||||
await access(oldPath, constants.F_OK);
|
||||
|
||||
// Rename the file
|
||||
await rename(oldPath, newPath);
|
||||
console.log(`Renamed ${dir}/${originalShortName}.webp -> ${dir}/${recipeData.short_name}.webp`);
|
||||
} catch (err) {
|
||||
// File might not exist or rename failed - log but continue
|
||||
console.warn(`Could not rename ${dir}/${originalShortName}.webp:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update image mediapath if it was using the old short_name
|
||||
if (recipeData.images[0].mediapath === `${originalShortName}.webp`) {
|
||||
recipeData.images[0].mediapath = `${recipeData.short_name}.webp`;
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize for database
|
||||
const recipe_json = serializeRecipeForDatabase(recipeData);
|
||||
|
||||
// Connect to database and update recipe
|
||||
await dbConnect();
|
||||
|
||||
try {
|
||||
const result = await Recipe.findOneAndUpdate(
|
||||
{ short_name: originalShortName },
|
||||
recipe_json,
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return fail(404, {
|
||||
error: `Recipe with short name "${originalShortName}" not found`,
|
||||
errors: ['Recipe not found'],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate recipe caches after successful update
|
||||
await invalidateRecipeCaches();
|
||||
|
||||
// Redirect to the updated recipe page (might have new short_name)
|
||||
throw redirect(303, `/${params.recipeLang}/${recipeData.short_name}`);
|
||||
} catch (dbError: any) {
|
||||
// Re-throw redirects (they're not errors)
|
||||
if (dbError?.status >= 300 && dbError?.status < 400) {
|
||||
throw dbError;
|
||||
}
|
||||
|
||||
console.error('Database error updating recipe:', dbError);
|
||||
|
||||
// Check for duplicate key error
|
||||
if (dbError.code === 11000) {
|
||||
return fail(400, {
|
||||
error: `A recipe with the short name "${recipeData.short_name}" already exists. Please choose a different short name.`,
|
||||
errors: ['Duplicate short_name'],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
|
||||
return fail(500, {
|
||||
error: `Failed to update recipe: ${dbError.message || 'Unknown database error'}`,
|
||||
errors: [dbError.message],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Re-throw redirects (they're not errors)
|
||||
if (error?.status >= 300 && error?.status < 400) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Error processing recipe update:', error);
|
||||
|
||||
return fail(500, {
|
||||
error: `Failed to process recipe update: ${error.message || 'Unknown error'}`,
|
||||
errors: [error.message],
|
||||
values: Object.fromEntries(formData)
|
||||
});
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import Check from '$lib/assets/icons/Check.svelte';
|
||||
import Cross from '$lib/assets/icons/Cross.svelte';
|
||||
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
||||
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
||||
import GenerateAltTextButton from '$lib/components/GenerateAltTextButton.svelte';
|
||||
import '$lib/css/action_button.css'
|
||||
import '$lib/css/nordtheme.css'
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import EditRecipeNote from '$lib/components/EditRecipeNote.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import CardAdd from '$lib/components/CardAdd.svelte';
|
||||
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
|
||||
import CreateStepList from '$lib/components/CreateStepList.svelte';
|
||||
import { season } from '$lib/js/season_store';
|
||||
import { portions } from '$lib/js/portions_store';
|
||||
import { img } from '$lib/js/img_store';
|
||||
import '$lib/css/action_button.css';
|
||||
import '$lib/css/nordtheme.css';
|
||||
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let preamble = $state(data.recipe.preamble);
|
||||
let addendum = $state(data.recipe.addendum);
|
||||
let image_preview_url = $state("https://bocken.org/static/rezepte/thumb/" + (data.recipe.images?.[0]?.mediapath || `${data.recipe.short_name}.webp`));
|
||||
let note = $state(data.recipe.note);
|
||||
// Recipe data state
|
||||
let preamble = $state(data.recipe.preamble || "");
|
||||
let addendum = $state(data.recipe.addendum || "");
|
||||
let note = $state(data.recipe.note || "");
|
||||
let image_preview_url = $state(
|
||||
"https://bocken.org/static/rezepte/thumb/" +
|
||||
(data.recipe.images?.[0]?.mediapath || `${data.recipe.short_name}.webp`)
|
||||
);
|
||||
let uploaded_image_filename = $state("");
|
||||
|
||||
// Translation workflow state
|
||||
let showTranslationWorkflow = $state(false);
|
||||
@@ -30,90 +34,102 @@
|
||||
|
||||
// Store original recipe data for change detection
|
||||
const originalRecipe = JSON.parse(JSON.stringify(data.recipe));
|
||||
const old_short_name = data.recipe.short_name;
|
||||
|
||||
portions.update(() => data.recipe.portions);
|
||||
let portions_local = $state<any>(data.recipe.portions);
|
||||
// Season and portions stores
|
||||
portions.update(() => data.recipe.portions || "");
|
||||
let portions_local = $state<string>(data.recipe.portions || "");
|
||||
$effect(() => {
|
||||
portions.subscribe((p) => {
|
||||
portions_local = p;
|
||||
});
|
||||
});
|
||||
|
||||
season.update(() => data.recipe.season);
|
||||
let season_local = $state<any>(data.recipe.season);
|
||||
season.update(() => data.recipe.season || []);
|
||||
let season_local = $state<number[]>(data.recipe.season || []);
|
||||
$effect(() => {
|
||||
season.subscribe((s) => {
|
||||
season_local = s;
|
||||
});
|
||||
});
|
||||
|
||||
let img_local = $state<string>('');
|
||||
img.update(() => '');
|
||||
$effect(() => {
|
||||
img.subscribe((i) => {
|
||||
img_local = i;
|
||||
});
|
||||
});
|
||||
|
||||
let old_short_name = $state(data.recipe.short_name);
|
||||
|
||||
let card_data = $state({
|
||||
icon: data.recipe.icon,
|
||||
category: data.recipe.category,
|
||||
name: data.recipe.name,
|
||||
description: data.recipe.description,
|
||||
tags: data.recipe.tags,
|
||||
icon: data.recipe.icon || "",
|
||||
category: data.recipe.category || "",
|
||||
name: data.recipe.name || "",
|
||||
description: data.recipe.description || "",
|
||||
tags: data.recipe.tags || [],
|
||||
});
|
||||
|
||||
let add_info = $state({
|
||||
preparation: data.recipe.preparation,
|
||||
preparation: data.recipe.preparation || "",
|
||||
fermentation: {
|
||||
bulk: data.recipe.fermentation.bulk,
|
||||
final: data.recipe.fermentation.final,
|
||||
bulk: data.recipe.fermentation?.bulk || "",
|
||||
final: data.recipe.fermentation?.final || "",
|
||||
},
|
||||
baking: {
|
||||
length: data.recipe.baking.length,
|
||||
temperature: data.recipe.baking.temperature,
|
||||
mode: data.recipe.baking.mode,
|
||||
length: data.recipe.baking?.length || "",
|
||||
temperature: data.recipe.baking?.temperature || "",
|
||||
mode: data.recipe.baking?.mode || "",
|
||||
},
|
||||
total_time: data.recipe.total_time,
|
||||
cooking: data.recipe.cooking,
|
||||
total_time: data.recipe.total_time || "",
|
||||
cooking: data.recipe.cooking || "",
|
||||
});
|
||||
|
||||
let images = $state(data.recipe.images);
|
||||
|
||||
let short_name = $state(data.recipe.short_name);
|
||||
let images = $state(data.recipe.images || []);
|
||||
let short_name = $state(data.recipe.short_name || "");
|
||||
let datecreated = $state(data.recipe.datecreated);
|
||||
let datemodified = $state(new Date());
|
||||
let isBaseRecipe = $state(data.recipe.isBaseRecipe || false);
|
||||
let ingredients = $state(data.recipe.ingredients || []);
|
||||
let instructions = $state(data.recipe.instructions || []);
|
||||
|
||||
let ingredients = $state(data.recipe.ingredients);
|
||||
let instructions = $state(data.recipe.instructions);
|
||||
// Form submission state
|
||||
let submitting = $state(false);
|
||||
let formElement: HTMLFormElement;
|
||||
|
||||
|
||||
function get_season(){
|
||||
let season = []
|
||||
// Get season data from checkboxes
|
||||
function get_season(): number[] {
|
||||
const season: number[] = [];
|
||||
const el = document.getElementById("labels");
|
||||
for(var i = 0; i < el.children.length; i++){
|
||||
if(el.children[i].children[0].children[0].checked){
|
||||
season.push(i+1)
|
||||
if (!el) return season;
|
||||
|
||||
for (let i = 0; i < el.children.length; i++) {
|
||||
const checkbox = el.children[i].children[0].children[0] as HTMLInputElement;
|
||||
if (checkbox?.checked) {
|
||||
season.push(i + 1);
|
||||
}
|
||||
}
|
||||
return season
|
||||
}
|
||||
function write_season(season){
|
||||
const el = document.getElementById("labels");
|
||||
for(var i = 0; i < season.length; i++){
|
||||
el.children[i].children[0].children[0].checked = true
|
||||
}
|
||||
return season;
|
||||
}
|
||||
|
||||
// Get current German recipe data
|
||||
function getCurrentRecipeData() {
|
||||
// Ensure we always have a valid images array with at least one item
|
||||
let recipeImages;
|
||||
if (uploaded_image_filename) {
|
||||
// New image uploaded
|
||||
recipeImages = [{
|
||||
mediapath: uploaded_image_filename,
|
||||
alt: images[0]?.alt || "",
|
||||
caption: images[0]?.caption || ""
|
||||
}];
|
||||
} else if (images && images.length > 0) {
|
||||
// Use existing images
|
||||
recipeImages = images;
|
||||
} else {
|
||||
// No images - use placeholder based on short_name
|
||||
recipeImages = [{
|
||||
mediapath: `${short_name.trim()}.webp`,
|
||||
alt: "",
|
||||
caption: ""
|
||||
}];
|
||||
}
|
||||
|
||||
return {
|
||||
...card_data,
|
||||
...add_info,
|
||||
images,
|
||||
images: recipeImages,
|
||||
season: season_local,
|
||||
short_name: short_name.trim(),
|
||||
datecreated,
|
||||
@@ -129,15 +145,14 @@
|
||||
}
|
||||
|
||||
// Detect which fields have changed from the original
|
||||
function detectChangedFields() {
|
||||
function detectChangedFields(): string[] {
|
||||
const current = getCurrentRecipeData();
|
||||
const changed: string[] = [];
|
||||
|
||||
const fieldsToCheck = [
|
||||
'name', 'description', 'preamble', 'addendum',
|
||||
'note', 'category', 'tags', 'portions', 'preparation',
|
||||
'cooking', 'total_time', 'baking', 'fermentation',
|
||||
'ingredients', 'instructions'
|
||||
'name', 'description', 'preamble', 'addendum', 'note',
|
||||
'category', 'tags', 'portions', 'preparation', 'cooking',
|
||||
'total_time', 'baking', 'fermentation', 'ingredients', 'instructions'
|
||||
];
|
||||
|
||||
for (const field of fieldsToCheck) {
|
||||
@@ -153,8 +168,17 @@
|
||||
|
||||
// Show translation workflow before submission
|
||||
function prepareSubmit() {
|
||||
// Client-side validation
|
||||
if (!short_name.trim()) {
|
||||
alert('Bitte geben Sie einen Kurznamen ein');
|
||||
return;
|
||||
}
|
||||
if (!card_data.name) {
|
||||
alert('Bitte geben Sie einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only detect changed fields if there's an existing translation
|
||||
// For first-time translations, changedFields should be empty
|
||||
changedFields = translationData ? detectChangedFields() : [];
|
||||
showTranslationWorkflow = true;
|
||||
|
||||
@@ -166,30 +190,36 @@
|
||||
|
||||
// Force full retranslation of entire recipe
|
||||
function forceFullRetranslation() {
|
||||
// Set changedFields to empty array to trigger full translation
|
||||
changedFields = [];
|
||||
showTranslationWorkflow = true;
|
||||
|
||||
// Scroll to translation section
|
||||
setTimeout(() => {
|
||||
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Handle translation approval
|
||||
// Handle translation approval - populate form and submit
|
||||
function handleTranslationApproved(event: CustomEvent) {
|
||||
translationData = event.detail.translatedRecipe;
|
||||
doEdit();
|
||||
|
||||
// Submit the form programmatically
|
||||
if (formElement) {
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle translation skipped
|
||||
// Handle translation skipped - submit without translation update
|
||||
function handleTranslationSkipped() {
|
||||
// Mark translation as needing update if fields changed
|
||||
if (changedFields.length > 0 && translationData) {
|
||||
translationData.translationStatus = 'needs_update';
|
||||
translationData.changedFields = changedFields;
|
||||
}
|
||||
doEdit();
|
||||
|
||||
// Submit the form programmatically
|
||||
if (formElement) {
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle translation cancelled
|
||||
@@ -197,438 +227,274 @@
|
||||
showTranslationWorkflow = false;
|
||||
}
|
||||
|
||||
async function doDelete(){
|
||||
// Check for references if this is a base recipe
|
||||
const checkRes = await fetch(`/api/rezepte/check-references/${data.recipe._id}`);
|
||||
const checkData = await checkRes.json();
|
||||
|
||||
let response;
|
||||
if (checkData.isReferenced) {
|
||||
const refList = checkData.references
|
||||
.map(r => ` • ${r.name}`)
|
||||
.join('\n');
|
||||
|
||||
response = confirm(
|
||||
`Dieses Rezept wird von folgenden Rezepten referenziert:\n\n${refList}\n\n` +
|
||||
`Die Referenzen werden in regulären Inhalt umgewandelt.\n` +
|
||||
`Möchtest du fortfahren?`
|
||||
);
|
||||
} else {
|
||||
response = confirm("Bist du dir sicher, dass du das Rezept löschen willst?");
|
||||
// Display form errors if any
|
||||
$effect(() => {
|
||||
if (form?.error) {
|
||||
alert(`Fehler: ${form.error}`);
|
||||
}
|
||||
|
||||
if(!response){
|
||||
return
|
||||
}
|
||||
const res_img = await fetch('/api/rezepte/img/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: old_short_name,
|
||||
}),
|
||||
headers : {
|
||||
'content-type': 'application/json',
|
||||
credentials: 'include',
|
||||
}
|
||||
})
|
||||
if(!res_img.ok){
|
||||
const item = await res_img.json();
|
||||
//alert(item.message)
|
||||
return
|
||||
}
|
||||
const res = await fetch('/api/rezepte/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
old_short_name,
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
credentials: 'include',
|
||||
}
|
||||
})
|
||||
if(res.ok){
|
||||
const url = location.href.split('/')
|
||||
url.splice(url.length -2, 2);
|
||||
location.assign(url.join('/'))
|
||||
}
|
||||
else{
|
||||
const item = await res.json();
|
||||
// alert(item.message)
|
||||
}
|
||||
}
|
||||
async function doEdit() {
|
||||
// two cases:
|
||||
//new image uploaded (not implemented yet)
|
||||
// new short_name -> move images as well
|
||||
|
||||
// if new image
|
||||
console.log("img_local", img_local)
|
||||
if(img_local != ""){
|
||||
async function delete_img(){
|
||||
const res = await fetch('/api/rezepte/img/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: old_short_name,
|
||||
}),
|
||||
headers : {
|
||||
'content-type': 'application/json',
|
||||
credentials: 'include',
|
||||
}
|
||||
})
|
||||
if(!res.ok){
|
||||
const item = await res.json();
|
||||
// alert(item.message)
|
||||
}
|
||||
}
|
||||
async function upload_img(){
|
||||
const data = {
|
||||
image: img_local,
|
||||
name: short_name.trim(),
|
||||
}
|
||||
const res = await fetch(`/api/rezepte/img/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
credentials: 'include',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if(!res.ok){
|
||||
const item = await res.json();
|
||||
// alert(item.message)
|
||||
}
|
||||
}
|
||||
delete_img()
|
||||
upload_img()
|
||||
}
|
||||
// case new short_name:
|
||||
else if(short_name != old_short_name){
|
||||
console.log("MOVING")
|
||||
const res_img = await fetch('/api/rezepte/img/mv', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
credentials: 'include',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
old_name: old_short_name,
|
||||
new_name: short_name.trim(),
|
||||
})
|
||||
})
|
||||
if(!res_img.ok){
|
||||
const item = await res_img.json();
|
||||
//alert(item.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
const recipeData = getCurrentRecipeData();
|
||||
|
||||
// Add translations if available
|
||||
if (translationData) {
|
||||
recipeData.translations = {
|
||||
en: translationData
|
||||
};
|
||||
|
||||
// Update translation metadata
|
||||
if (changedFields.length > 0) {
|
||||
recipeData.translationMetadata = {
|
||||
lastModifiedGerman: new Date(),
|
||||
fieldsModifiedSinceTranslation: translationData.translationStatus === 'needs_update' ? changedFields : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch('/api/rezepte/edit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipe: recipeData,
|
||||
old_short_name,
|
||||
old_recipe: originalRecipe, // For change detection in API
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
credentials: 'include',
|
||||
}
|
||||
})
|
||||
if(res.ok){
|
||||
const url = location.href.split('/');
|
||||
url.splice(url.length -2, 2);
|
||||
url.push(short_name.trim());
|
||||
location.assign(url.join('/'))
|
||||
}
|
||||
else{
|
||||
const item = await res.json()
|
||||
//alert(item.message)
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
input{
|
||||
display: block;
|
||||
border: unset;
|
||||
margin: 1rem auto;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 1000px;
|
||||
background-color: var(--nord4);
|
||||
font-size: 1.1rem;
|
||||
transition: 100ms;
|
||||
|
||||
}
|
||||
input:hover,
|
||||
input:focus-visible
|
||||
{
|
||||
scale: 1.05 1.05;
|
||||
}
|
||||
.list_wrapper{
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 1000px;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
@media screen and (max-width: 700px){
|
||||
.list_wrapper{
|
||||
input {
|
||||
display: block;
|
||||
border: unset;
|
||||
margin: 1rem auto;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 1000px;
|
||||
background-color: var(--nord4);
|
||||
font-size: 1.1rem;
|
||||
transition: 100ms;
|
||||
}
|
||||
input:hover,
|
||||
input:focus-visible {
|
||||
scale: 1.05 1.05;
|
||||
}
|
||||
.list_wrapper {
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 1000px;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
@media screen and (max-width: 700px) {
|
||||
.list_wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.title_container {
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix button icon visibility in dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.list_wrapper :global(svg) {
|
||||
fill: white !important;
|
||||
.title {
|
||||
position: relative;
|
||||
width: min(800px, 80vw);
|
||||
margin-block: 2rem;
|
||||
margin-inline: auto;
|
||||
background-color: var(--nord6);
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
.list_wrapper :global(.button_arrow) {
|
||||
fill: var(--nord4) !important;
|
||||
.title p {
|
||||
border: 2px solid var(--nord1);
|
||||
border-radius: 10000px;
|
||||
padding: 0.5em 1em;
|
||||
font-size: 1.1rem;
|
||||
transition: 200ms;
|
||||
}
|
||||
}
|
||||
|
||||
h1{
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.title_container{
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.title{
|
||||
position: relative;
|
||||
width: min(800px, 80vw);
|
||||
margin-block: 2rem;
|
||||
margin-inline: auto;
|
||||
background-color: var(--nord6);
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
.title{
|
||||
background-color: var(--nord6-dark);
|
||||
.title p:hover,
|
||||
.title p:focus-within {
|
||||
scale: 1.02 1.02;
|
||||
}
|
||||
}
|
||||
.title p{
|
||||
border: 2px solid var(--nord1);
|
||||
border-radius: 10000px;
|
||||
padding: 0.5em 1em;
|
||||
font-size: 1.1rem;
|
||||
transition: 200ms;
|
||||
}
|
||||
.title p:hover,
|
||||
.title p:focus-within{
|
||||
scale: 1.02 1.02;
|
||||
}
|
||||
.addendum{
|
||||
font-size: 1.1rem;
|
||||
max-width: 90%;
|
||||
margin-inline: auto;
|
||||
border: 2px solid var(--nord1);
|
||||
border-radius: 45px;
|
||||
padding: 1em 1em;
|
||||
transition: 100ms;
|
||||
}
|
||||
.addendum:hover,
|
||||
.addendum:focus-within
|
||||
{
|
||||
scale: 1.02 1.02;
|
||||
}
|
||||
.addendum_wrapper{
|
||||
max-width: 1000px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
h3{
|
||||
text-align: center;
|
||||
}
|
||||
button.action_button{
|
||||
animation: unset !important;
|
||||
font-size: 1.3rem;
|
||||
color: white;
|
||||
}
|
||||
.submit_buttons{
|
||||
display: flex;
|
||||
margin-inline: auto;
|
||||
max-width: 1000px;
|
||||
margin-block: 1rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.submit_buttons p{
|
||||
padding: 0;
|
||||
padding-right: 0.5em;
|
||||
margin: 0;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:global(body){
|
||||
background-color: var(--background-dark);
|
||||
.addendum {
|
||||
font-size: 1.1rem;
|
||||
max-width: 90%;
|
||||
margin-inline: auto;
|
||||
border: 2px solid var(--nord1);
|
||||
border-radius: 45px;
|
||||
padding: 1em 1em;
|
||||
transition: 100ms;
|
||||
}
|
||||
:global(.image-management-section) {
|
||||
background-color: var(--nord1) !important;
|
||||
.addendum:hover,
|
||||
.addendum:focus-within {
|
||||
scale: 1.02 1.02;
|
||||
}
|
||||
:global(.image-item) {
|
||||
background-color: var(--nord0) !important;
|
||||
border-color: var(--nord2) !important;
|
||||
.addendum_wrapper {
|
||||
max-width: 1000px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
:global(.image-item input) {
|
||||
background-color: var(--nord2) !important;
|
||||
color: white !important;
|
||||
border-color: var(--nord3) !important;
|
||||
h3 {
|
||||
text-align: center;
|
||||
}
|
||||
button.action_button {
|
||||
animation: unset !important;
|
||||
font-size: 1.3rem;
|
||||
color: white;
|
||||
}
|
||||
.submit_buttons {
|
||||
display: flex;
|
||||
margin-inline: auto;
|
||||
max-width: 1000px;
|
||||
margin-block: 1rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.submit_buttons p {
|
||||
padding: 0;
|
||||
padding-right: 0.5em;
|
||||
margin: 0;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.title {
|
||||
background-color: var(--nord6-dark);
|
||||
}
|
||||
}
|
||||
.error-message {
|
||||
background: var(--nord11);
|
||||
color: var(--nord6);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem auto;
|
||||
max-width: 800px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<h1>Rezept editieren</h1>
|
||||
<CardAdd {card_data} {image_preview_url} ></CardAdd>
|
||||
|
||||
{#if images && images.length > 0}
|
||||
<div class="image-management-section" style="background-color: var(--nord6); padding: 1.5rem; margin: 2rem auto; max-width: 800px; border-radius: 10px;">
|
||||
<h3 style="margin-top: 0;">🖼️ Bilder & Alt-Texte</h3>
|
||||
{#each images as image, i}
|
||||
<div class="image-item" style="background-color: white; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; border: 1px solid var(--nord4);">
|
||||
<div style="display: flex; gap: 1rem; align-items: start;">
|
||||
<img
|
||||
src="https://bocken.org/static/rezepte/thumb/{image.mediapath}"
|
||||
alt={image.alt || 'Recipe image'}
|
||||
style="width: 120px; height: 120px; object-fit: cover; border-radius: 5px;"
|
||||
/>
|
||||
<div style="flex: 1;">
|
||||
<p style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: var(--nord3);"><strong>Bild {i + 1}:</strong> {image.mediapath}</p>
|
||||
<svelte:head>
|
||||
<title>Rezept bearbeiten - {data.recipe.name}</title>
|
||||
<meta name="description" content="Bearbeite das Rezept {data.recipe.name}" />
|
||||
</svelte:head>
|
||||
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<label for="image-alt-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.9rem;">Alt-Text (DE):</label>
|
||||
<input
|
||||
id="image-alt-{i}"
|
||||
type="text"
|
||||
bind:value={image.alt}
|
||||
placeholder="Beschreibung des Bildes für Screenreader (Deutsch)"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid var(--nord4); border-radius: 3px;"
|
||||
/>
|
||||
</div>
|
||||
<h1>Rezept bearbeiten</h1>
|
||||
|
||||
<div style="margin-bottom: 0.75rem;">
|
||||
<label for="image-caption-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.9rem;">Caption (DE):</label>
|
||||
<input
|
||||
id="image-caption-{i}"
|
||||
type="text"
|
||||
bind:value={image.caption}
|
||||
placeholder="Bildunterschrift (optional)"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid var(--nord4); border-radius: 3px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GenerateAltTextButton shortName={data.recipe.short_name} imageIndex={i} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h3>Kurzname (für URL):</h3>
|
||||
<input bind:value={short_name} placeholder="Kurzname"/>
|
||||
|
||||
<div style="text-align: center; margin: 1rem;">
|
||||
<label style="font-size: 1.1rem; cursor: pointer;">
|
||||
<input type="checkbox" bind:checked={isBaseRecipe} style="width: auto; display: inline; margin-right: 0.5em;" />
|
||||
Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if isBaseRecipe}
|
||||
<div style="background-color: var(--nord14); padding: 1.5rem; margin: 1rem auto; max-width: 600px; border-radius: 10px; border: 2px solid var(--nord9);">
|
||||
<h3 style="margin-top: 0; color: var(--nord0);">📋 Basisrezept-Informationen</h3>
|
||||
{#await fetch(`/api/rezepte/check-references/${data.recipe._id}`).then(r => r.json())}
|
||||
<p style="color: var(--nord3);">Lade Referenzen...</p>
|
||||
{:then refData}
|
||||
{#if refData.isReferenced}
|
||||
<h4 style="color: var(--nord0);">Wird referenziert von:</h4>
|
||||
<ul style="color: var(--nord1); list-style-position: inside;">
|
||||
{#each refData.references as ref}
|
||||
<li>
|
||||
<a href="/rezepte/edit/{ref.short_name}" style="color: var(--nord10); font-weight: bold; text-decoration: underline;">
|
||||
{ref.name}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p style="color: var(--nord11); font-weight: bold; margin-top: 1rem;">
|
||||
⚠️ Änderungen an diesem Basisrezept wirken sich auf alle referenzierenden Rezepte aus.
|
||||
</p>
|
||||
{:else}
|
||||
<p style="color: var(--nord3);">Dieses Basisrezept wird noch nicht referenziert.</p>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<p style="color: var(--nord11);">Fehler beim Laden der Referenzen.</p>
|
||||
{/await}
|
||||
{#if form?.error}
|
||||
<div class="error-message">
|
||||
<strong>Fehler:</strong> {form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=title_container>
|
||||
<div class=title>
|
||||
<h4>Eine etwas längere Beschreibung:</h4>
|
||||
<p bind:innerText={preamble} contenteditable></p>
|
||||
<div class=tags>
|
||||
<h4>Saison:</h4>
|
||||
<SeasonSelect></SeasonSelect>
|
||||
<EditRecipeNote><p contenteditable bind:innerText={note}></p></EditRecipeNote>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
bind:this={formElement}
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
submitting = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<!-- 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="existing_image_path" value={images[0]?.mediapath || `${old_short_name}.webp`} />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden inputs for complex nested data -->
|
||||
<input type="hidden" name="ingredients_json" value={JSON.stringify(ingredients)} />
|
||||
<input type="hidden" name="instructions_json" value={JSON.stringify(instructions)} />
|
||||
<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()} />
|
||||
|
||||
<div class=list_wrapper>
|
||||
<div>
|
||||
<CreateIngredientList {ingredients}></CreateIngredientList>
|
||||
</div>
|
||||
<div>
|
||||
<CreateStepList {instructions} {add_info}></CreateStepList>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Translation data (updated after approval or marked needs_update) -->
|
||||
{#if translationData}
|
||||
<input type="hidden" name="translation_json" value={JSON.stringify(translationData)} />
|
||||
<input type="hidden" name="translation_metadata_json" value={JSON.stringify({
|
||||
lastModifiedGerman: new Date(),
|
||||
fieldsModifiedSinceTranslation: changedFields
|
||||
})} />
|
||||
{/if}
|
||||
|
||||
<div class=addendum_wrapper>
|
||||
<h3>Nachtrag:</h3>
|
||||
<div class=addendum bind:innerText={addendum} contenteditable></div>
|
||||
</div>
|
||||
<CardAdd
|
||||
bind:card_data
|
||||
bind:image_preview_url
|
||||
bind:uploaded_image_filename
|
||||
short_name={short_name}
|
||||
/>
|
||||
|
||||
{#if !showTranslationWorkflow}
|
||||
<div class=submit_buttons>
|
||||
<button class=action_button onclick={doDelete}><p>Löschen</p><Cross fill=white width=2rem height=2rem></Cross></button>
|
||||
<button class=action_button onclick={prepareSubmit}><p>Weiter zur Übersetzung</p><Check fill=white width=2rem height=2rem></Check></button>
|
||||
</div>
|
||||
{/if}
|
||||
<h3>Kurzname (für URL):</h3>
|
||||
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
|
||||
|
||||
<!-- Hidden inputs for card data -->
|
||||
<input type="hidden" name="name" value={card_data.name} />
|
||||
<input type="hidden" name="description" value={card_data.description} />
|
||||
<input type="hidden" name="category" value={card_data.category} />
|
||||
<input type="hidden" name="icon" value={card_data.icon} />
|
||||
<input type="hidden" name="portions" value={portions_local} />
|
||||
|
||||
<div style="text-align: center; margin: 1rem;">
|
||||
<label style="font-size: 1.1rem; cursor: pointer;">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isBaseRecipe"
|
||||
bind:checked={isBaseRecipe}
|
||||
style="width: auto; display: inline; margin-right: 0.5em;"
|
||||
/>
|
||||
Als Basisrezept markieren (kann von anderen Rezepten referenziert werden)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Note Component -->
|
||||
<EditRecipeNote bind:note />
|
||||
<input type="hidden" name="note" value={note} />
|
||||
|
||||
<div class="title_container">
|
||||
<div class="title">
|
||||
<h4>Eine etwas längere Beschreibung:</h4>
|
||||
<p bind:innerText={preamble} contenteditable></p>
|
||||
<input type="hidden" name="preamble" value={preamble} />
|
||||
|
||||
<div class="tags">
|
||||
<h4>Saison:</h4>
|
||||
<SeasonSelect />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list_wrapper">
|
||||
<div>
|
||||
<CreateIngredientList bind:ingredients />
|
||||
</div>
|
||||
<div>
|
||||
<CreateStepList bind:instructions bind:add_info />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="addendum_wrapper">
|
||||
<h3>Nachtrag:</h3>
|
||||
<div class="addendum" bind:innerText={addendum} contenteditable></div>
|
||||
<input type="hidden" name="addendum" value={addendum} />
|
||||
</div>
|
||||
|
||||
{#if !showTranslationWorkflow}
|
||||
<div class="submit_buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="action_button"
|
||||
onclick={prepareSubmit}
|
||||
disabled={submitting}
|
||||
style="background-color: var(--nord14);"
|
||||
>
|
||||
<p>Speichern & Übersetzung aktualisieren</p>
|
||||
<Check fill="white" width="2rem" height="2rem" />
|
||||
</button>
|
||||
{#if translationData}
|
||||
<button
|
||||
type="button"
|
||||
class="action_button"
|
||||
onclick={forceFullRetranslation}
|
||||
disabled={submitting}
|
||||
style="background-color: var(--nord12);"
|
||||
>
|
||||
<p>Komplett neu übersetzen</p>
|
||||
<Check fill="white" width="2rem" height="2rem" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if showTranslationWorkflow}
|
||||
<div id="translation-section">
|
||||
<TranslationApproval
|
||||
germanData={getCurrentRecipeData()}
|
||||
englishData={translationData}
|
||||
oldRecipeData={originalRecipe}
|
||||
{changedFields}
|
||||
isEditMode={true}
|
||||
onapproved={handleTranslationApproved}
|
||||
onskipped={handleTranslationSkipped}
|
||||
oncancelled={handleTranslationCancelled}
|
||||
onforceFullRetranslation={forceFullRetranslation}
|
||||
/>
|
||||
</div>
|
||||
<div id="translation-section">
|
||||
<TranslationApproval
|
||||
germanData={getCurrentRecipeData()}
|
||||
englishData={translationData}
|
||||
changedFields={changedFields}
|
||||
isEditMode={true}
|
||||
oldRecipeData={originalRecipe}
|
||||
onapproved={handleTranslationApproved}
|
||||
onskipped={handleTranslationSkipped}
|
||||
oncancelled={handleTranslationCancelled}
|
||||
onforceFullRetranslation={forceFullRetranslation}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,66 +1,114 @@
|
||||
import path from 'path'
|
||||
import path from 'path';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { IMAGE_DIR } from '$env/static/private'
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { IMAGE_DIR } from '$env/static/private';
|
||||
import sharp from 'sharp';
|
||||
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
||||
import { validateImageFile } from '$utils/imageValidation';
|
||||
|
||||
export const POST = (async ({ request, locals}) => {
|
||||
const data = await request.json();
|
||||
/**
|
||||
* Secure image upload endpoint for recipe images
|
||||
*
|
||||
* SECURITY:
|
||||
* - Requires authentication
|
||||
* - 5-layer validation (size, magic bytes, MIME, extension, Sharp)
|
||||
* - Uses FormData instead of base64 JSON (more efficient, more secure)
|
||||
* - Generates full/thumb/placeholder versions
|
||||
* - Content hash for cache busting
|
||||
*
|
||||
* @route POST /api/rezepte/img/add
|
||||
*/
|
||||
export const POST = (async ({ request, locals }) => {
|
||||
// Check authentication
|
||||
const auth = await locals.auth();
|
||||
if (!auth) throw error(401, "Need to be logged in")
|
||||
let full_res = new Buffer.from(data.image, 'base64')
|
||||
if (!auth) {
|
||||
throw error(401, 'Authentication required to upload images');
|
||||
}
|
||||
|
||||
// Generate content hash for cache busting
|
||||
const imageHash = generateImageHashFromBuffer(full_res);
|
||||
const hashedFilename = getHashedFilename(data.name, imageHash);
|
||||
const unhashedFilename = data.name + '.webp';
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
// reduce image size if over 500KB
|
||||
const MAX_SIZE_KB = 500
|
||||
//const metadata = await sharp(full_res).metadata()
|
||||
////reduce image size if larger than 500KB
|
||||
//if(metadata.size > MAX_SIZE_KB*1000){
|
||||
// full_res = sharp(full_res).
|
||||
// webp( { quality: 70})
|
||||
// .toBuffer()
|
||||
//}
|
||||
// Extract image file and filename
|
||||
const image = formData.get('image') as File;
|
||||
const name = formData.get('name')?.toString().trim();
|
||||
|
||||
// Save full size - both hashed and unhashed versions
|
||||
const fullBuffer = await sharp(full_res)
|
||||
.toFormat('webp')
|
||||
.toBuffer();
|
||||
if (!image) {
|
||||
throw error(400, 'No image file provided');
|
||||
}
|
||||
|
||||
await sharp(fullBuffer)
|
||||
.toFile(path.join(IMAGE_DIR, "rezepte", "full", hashedFilename));
|
||||
await sharp(fullBuffer)
|
||||
.toFile(path.join(IMAGE_DIR, "rezepte", "full", unhashedFilename));
|
||||
if (!name) {
|
||||
throw error(400, 'Image name is required');
|
||||
}
|
||||
|
||||
// Save thumbnail - both hashed and unhashed versions
|
||||
const thumbBuffer = await sharp(full_res)
|
||||
.resize({ width: 800})
|
||||
.toFormat('webp')
|
||||
.toBuffer();
|
||||
// Comprehensive security validation
|
||||
const validationResult = await validateImageFile(image);
|
||||
if (!validationResult.valid) {
|
||||
throw error(400, validationResult.error || 'Invalid image file');
|
||||
}
|
||||
|
||||
await sharp(thumbBuffer)
|
||||
.toFile(path.join(IMAGE_DIR, "rezepte", "thumb", hashedFilename));
|
||||
await sharp(thumbBuffer)
|
||||
.toFile(path.join(IMAGE_DIR, "rezepte", "thumb", unhashedFilename));
|
||||
// Convert File to Buffer for processing
|
||||
const arrayBuffer = await image.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
// Save placeholder - both hashed and unhashed versions
|
||||
const placeholderBuffer = await sharp(full_res)
|
||||
.resize({ width: 20})
|
||||
.toFormat('webp')
|
||||
.toBuffer();
|
||||
// Generate content hash for cache busting
|
||||
const imageHash = generateImageHashFromBuffer(buffer);
|
||||
const hashedFilename = getHashedFilename(name, imageHash);
|
||||
const unhashedFilename = name + '.webp';
|
||||
|
||||
await sharp(placeholderBuffer)
|
||||
.toFile(path.join(IMAGE_DIR, "rezepte", "placeholder", hashedFilename));
|
||||
await sharp(placeholderBuffer)
|
||||
.toFile(path.join(IMAGE_DIR, "rezepte", "placeholder", unhashedFilename))
|
||||
return new Response(JSON.stringify({
|
||||
msg: "Added image successfully",
|
||||
filename: hashedFilename
|
||||
}),{
|
||||
status: 200,
|
||||
});
|
||||
// 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(IMAGE_DIR, 'rezepte', 'full', hashedFilename)
|
||||
);
|
||||
await sharp(fullBuffer).toFile(
|
||||
path.join(IMAGE_DIR, '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(IMAGE_DIR, 'rezepte', 'thumb', hashedFilename)
|
||||
);
|
||||
await sharp(thumbBuffer).toFile(
|
||||
path.join(IMAGE_DIR, '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(IMAGE_DIR, 'rezepte', 'placeholder', hashedFilename)
|
||||
);
|
||||
await sharp(placeholderBuffer).toFile(
|
||||
path.join(IMAGE_DIR, 'rezepte', 'placeholder', unhashedFilename)
|
||||
);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
msg: 'Image uploaded successfully',
|
||||
filename: hashedFilename,
|
||||
unhashedFilename: unhashedFilename
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Re-throw errors that already have status codes
|
||||
if (err.status) throw err;
|
||||
|
||||
// Log and throw generic error for unexpected failures
|
||||
console.error('Image upload error:', err);
|
||||
throw error(500, `Failed to upload image: ${err.message || 'Unknown error'}`);
|
||||
}
|
||||
}) satisfies RequestHandler;
|
||||
|
||||
Reference in New Issue
Block a user