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:
@@ -14,11 +14,15 @@
|
|||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:docker:up": "docker compose -f docker-compose.test.yml up -d",
|
||||||
|
"test:e2e:docker:down": "docker compose -f docker-compose.test.yml down -v",
|
||||||
|
"test:e2e:docker": "docker compose -f docker-compose.test.yml up -d && playwright test; docker compose -f docker-compose.test.yml down -v",
|
||||||
|
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.0.0",
|
"packageManager": "pnpm@9.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "1.56.1",
|
||||||
"@sveltejs/adapter-auto": "^6.1.0",
|
"@sveltejs/adapter-auto": "^6.1.0",
|
||||||
"@sveltejs/kit": "^2.37.0",
|
"@sveltejs/kit": "^2.37.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.1.3",
|
"@sveltejs/vite-plugin-svelte": "^6.1.3",
|
||||||
@@ -40,6 +44,7 @@
|
|||||||
"@sveltejs/adapter-node": "^5.0.0",
|
"@sveltejs/adapter-node": "^5.0.0",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
"cheerio": "1.0.0-rc.12",
|
"cheerio": "1.0.0-rc.12",
|
||||||
|
"file-type": "^19.0.0",
|
||||||
"ioredis": "^5.9.0",
|
"ioredis": "^5.9.0",
|
||||||
"mongoose": "^8.0.0",
|
"mongoose": "^8.0.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
@@ -5,48 +5,134 @@ import "$lib/css/shake.css"
|
|||||||
import "$lib/css/icon.css"
|
import "$lib/css/icon.css"
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
let { card_data = $bindable(), image_preview_url = $bindable() } = $props<{ card_data: any, image_preview_url: string }>();
|
let {
|
||||||
|
card_data = $bindable(),
|
||||||
|
image_preview_url = $bindable(),
|
||||||
|
uploaded_image_filename = $bindable(''),
|
||||||
|
short_name = ''
|
||||||
|
} = $props<{
|
||||||
|
card_data: any,
|
||||||
|
image_preview_url: string,
|
||||||
|
uploaded_image_filename?: string,
|
||||||
|
short_name: string
|
||||||
|
}>();
|
||||||
|
|
||||||
onMount( () => {
|
// Check if image redirects to placeholder by attempting to load it
|
||||||
fetch(image_preview_url, { method: 'HEAD' })
|
onMount(() => {
|
||||||
.then(response => {
|
if (image_preview_url) {
|
||||||
if(response.redirected){
|
const img = new Image();
|
||||||
image_preview_url = ""
|
|
||||||
}
|
img.onload = () => {
|
||||||
})
|
// Check if this is the placeholder image (150x150)
|
||||||
|
if (img.naturalWidth === 150 && img.naturalHeight === 150) {
|
||||||
|
console.log('Detected placeholder image (150x150), clearing preview');
|
||||||
|
image_preview_url = ""
|
||||||
|
} else {
|
||||||
|
console.log('Real image loaded:', {
|
||||||
|
url: image_preview_url,
|
||||||
|
naturalWidth: img.naturalWidth,
|
||||||
|
naturalHeight: img.naturalHeight
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
// Image failed to load - could be 404 or network error
|
||||||
|
console.log('Image failed to load, clearing preview');
|
||||||
|
image_preview_url = ""
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = image_preview_url;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
import { img } from '$lib/js/img_store';
|
|
||||||
|
|
||||||
if(!card_data.tags){
|
if(!card_data.tags){
|
||||||
card_data.tags = []
|
card_data.tags = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//locals
|
//locals
|
||||||
let new_tag = $state("");
|
let new_tag = $state("");
|
||||||
|
let uploading = $state(false);
|
||||||
|
let upload_error = $state("");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles image file selection and upload
|
||||||
|
* Now uses FormData instead of base64 encoding for better security and performance
|
||||||
|
*/
|
||||||
|
export async function show_local_image(){
|
||||||
|
const file = this.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
export function show_local_image(){
|
// Client-side validation
|
||||||
var file = this.files[0]
|
const allowed_mime_types = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
|
||||||
// allowed MIME types
|
const max_size = 5 * 1024 * 1024; // 5MB
|
||||||
var mime_types = [ 'image/webp' ];
|
|
||||||
|
|
||||||
// validate MIME
|
// Validate MIME type
|
||||||
if(mime_types.indexOf(file.type) == -1) {
|
if(!allowed_mime_types.includes(file.type)) {
|
||||||
alert('Error : Incorrect file type');
|
upload_error = 'Invalid file type. Please upload a JPEG, PNG, or WebP image.';
|
||||||
return;
|
alert(upload_error);
|
||||||
}
|
return;
|
||||||
image_preview_url = URL.createObjectURL(file);
|
}
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(file);
|
// Validate file size
|
||||||
reader.onload = e => {
|
if(file.size > max_size) {
|
||||||
img.update(() => e.target.result.split(',')[1]);
|
upload_error = `File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`;
|
||||||
};
|
alert(upload_error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview immediately
|
||||||
|
image_preview_url = URL.createObjectURL(file);
|
||||||
|
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.unhashedFilename;
|
||||||
|
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 = "";
|
||||||
|
upload_error = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -344,6 +430,11 @@ input::placeholder{
|
|||||||
.tag_input{
|
.tag_input{
|
||||||
width: 12ch;
|
width: 12ch;
|
||||||
}
|
}
|
||||||
|
.upload-spinner {
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -362,11 +453,15 @@ input::placeholder{
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
<label class=img_label for=img_picker>
|
<label class=img_label for=img_picker style={uploading ? 'opacity: 0.5; cursor: not-allowed;' : ''}>
|
||||||
<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 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>
|
||||||
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id=img_picker accept="image/webp image/jpeg" onchange={show_local_image}>
|
<input type="file" id=img_picker accept="image/webp,image/jpeg,image/jpg,image/png" onchange={show_local_image} disabled={uploading}>
|
||||||
<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>
|
||||||
|
|||||||
@@ -737,10 +737,10 @@ h3{
|
|||||||
<div class="reference-container">
|
<div class="reference-container">
|
||||||
<div class="reference-header">
|
<div class="reference-header">
|
||||||
<div class="move_buttons_container">
|
<div class="move_buttons_container">
|
||||||
<button onclick={() => update_list_position(list_index, 1)} aria-label={t[lang].moveReferenceUpAria}>
|
<button type="button" onclick={() => update_list_position(list_index, 1)} aria-label={t[lang].moveReferenceUpAria}>
|
||||||
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => update_list_position(list_index, -1)} aria-label={t[lang].moveReferenceDownAria}>
|
<button type="button" onclick={() => update_list_position(list_index, -1)} aria-label={t[lang].moveReferenceDownAria}>
|
||||||
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -748,7 +748,7 @@ h3{
|
|||||||
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
|
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
|
||||||
</div>
|
</div>
|
||||||
<div class="mod_icons">
|
<div class="mod_icons">
|
||||||
<button class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
|
||||||
<Cross fill="var(--nord11)"></Cross>
|
<Cross fill="var(--nord11)"></Cross>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -762,24 +762,24 @@ h3{
|
|||||||
<div class=move_buttons_container>
|
<div class=move_buttons_container>
|
||||||
<!-- Empty for consistency -->
|
<!-- Empty for consistency -->
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => editItemFromReference(list_index, 'before', item_index)} class="ingredient-amount-button">
|
<button type="button" onclick={() => editItemFromReference(list_index, 'before', item_index)} class="ingredient-amount-button">
|
||||||
{item.amount} {item.unit}
|
{item.amount} {item.unit}
|
||||||
</button>
|
</button>
|
||||||
<button class="force_wrap ingredient-name-button" onclick={() => editItemFromReference(list_index, 'before', item_index)}>
|
<button type="button" class="force_wrap ingredient-name-button" onclick={() => editItemFromReference(list_index, 'before', item_index)}>
|
||||||
{@html item.name}
|
{@html item.name}
|
||||||
</button>
|
</button>
|
||||||
<div class="mod_icons">
|
<div class="mod_icons">
|
||||||
<button class="action_button button_subtle" onclick={() => editItemFromReference(list_index, 'before', item_index)} aria-label={t[lang].editIngredientAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => editItemFromReference(list_index, 'before', item_index)} aria-label={t[lang].editIngredientAria}>
|
||||||
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
|
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
|
||||||
</button>
|
</button>
|
||||||
<button class="action_button button_subtle" onclick={() => removeItemFromReference(list_index, 'before', item_index)} aria-label={t[lang].removeIngredientAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => removeItemFromReference(list_index, 'before', item_index)} aria-label={t[lang].removeIngredientAria}>
|
||||||
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
|
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'before')}>
|
<button type="button" class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'before')}>
|
||||||
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addIngredientBefore}
|
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addIngredientBefore}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -789,7 +789,7 @@ h3{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Items after base recipe -->
|
<!-- Items after base recipe -->
|
||||||
<button class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'after')}>
|
<button type="button" class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'after')}>
|
||||||
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addIngredientAfter}
|
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addIngredientAfter}
|
||||||
</button>
|
</button>
|
||||||
{#if list.itemsAfter && list.itemsAfter.length > 0}
|
{#if list.itemsAfter && list.itemsAfter.length > 0}
|
||||||
@@ -799,17 +799,17 @@ h3{
|
|||||||
<div class=move_buttons_container>
|
<div class=move_buttons_container>
|
||||||
<!-- Empty for consistency -->
|
<!-- Empty for consistency -->
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => editItemFromReference(list_index, 'after', item_index)} class="ingredient-amount-button">
|
<button type="button" onclick={() => editItemFromReference(list_index, 'after', item_index)} class="ingredient-amount-button">
|
||||||
{item.amount} {item.unit}
|
{item.amount} {item.unit}
|
||||||
</button>
|
</button>
|
||||||
<button class="force_wrap ingredient-name-button" onclick={() => editItemFromReference(list_index, 'after', item_index)}>
|
<button type="button" class="force_wrap ingredient-name-button" onclick={() => editItemFromReference(list_index, 'after', item_index)}>
|
||||||
{@html item.name}
|
{@html item.name}
|
||||||
</button>
|
</button>
|
||||||
<div class="mod_icons">
|
<div class="mod_icons">
|
||||||
<button class="action_button button_subtle" onclick={() => editItemFromReference(list_index, 'after', item_index)} aria-label={t[lang].editIngredientAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => editItemFromReference(list_index, 'after', item_index)} aria-label={t[lang].editIngredientAria}>
|
||||||
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
|
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
|
||||||
</button>
|
</button>
|
||||||
<button class="action_button button_subtle" onclick={() => removeItemFromReference(list_index, 'after', item_index)} aria-label={t[lang].removeIngredientAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => removeItemFromReference(list_index, 'after', item_index)} aria-label={t[lang].removeIngredientAria}>
|
||||||
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
|
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -821,15 +821,15 @@ h3{
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<h3>
|
<h3>
|
||||||
<div class=move_buttons_container>
|
<div class=move_buttons_container>
|
||||||
<button onclick="{() => update_list_position(list_index, 1)}" aria-label="Liste nach oben verschieben">
|
<button type="button" onclick="{() => update_list_position(list_index, 1)}" aria-label="Liste nach oben verschieben">
|
||||||
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="{() => update_list_position(list_index, -1)}" aria-label="Liste nach unten verschieben">
|
<button type="button" onclick="{() => update_list_position(list_index, -1)}" aria-label="Liste nach unten verschieben">
|
||||||
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button onclick="{() => show_modal_edit_subheading_ingredient(list_index)}" class="subheading-button">
|
<button type="button" onclick="{() => show_modal_edit_subheading_ingredient(list_index)}" class="subheading-button">
|
||||||
{#if list.name }
|
{#if list.name }
|
||||||
{list.name}
|
{list.name}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -837,38 +837,38 @@ h3{
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<div class=mod_icons>
|
<div class=mod_icons>
|
||||||
<button class="action_button button_subtle" onclick="{() => show_modal_edit_subheading_ingredient(list_index)}" aria-label={t[lang].editHeading}>
|
<button type="button" class="action_button button_subtle" onclick="{() => show_modal_edit_subheading_ingredient(list_index)}" aria-label={t[lang].editHeading}>
|
||||||
<Pen fill=var(--nord1)></Pen> </button>
|
<Pen fill=var(--nord1)></Pen> </button>
|
||||||
<button class="action_button button_subtle" onclick="{() => remove_list(list_index)}" aria-label={t[lang].removeList}>
|
<button type="button" class="action_button button_subtle" onclick="{() => remove_list(list_index)}" aria-label={t[lang].removeList}>
|
||||||
<Cross fill=var(--nord1)></Cross></button>
|
<Cross fill=var(--nord1)></Cross></button>
|
||||||
</div>
|
</div>
|
||||||
</h3>
|
</h3>
|
||||||
<div class=ingredients_grid>
|
<div class=ingredients_grid>
|
||||||
{#each list.list as ingredient, ingredient_index (ingredient_index)}
|
{#each list.list as ingredient, ingredient_index (ingredient_index)}
|
||||||
<div class=move_buttons_container>
|
<div class=move_buttons_container>
|
||||||
<button onclick="{() => update_ingredient_position(list_index, ingredient_index, 1)}" aria-label={t[lang].moveUpAria}>
|
<button type="button" onclick="{() => update_ingredient_position(list_index, ingredient_index, 1)}" aria-label={t[lang].moveUpAria}>
|
||||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="{() => update_ingredient_position(list_index, ingredient_index, -1)}" aria-label={t[lang].moveDownAria}>
|
<button type="button" onclick="{() => update_ingredient_position(list_index, ingredient_index, -1)}" aria-label={t[lang].moveDownAria}>
|
||||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)} class="ingredient-amount-button">
|
<button type="button" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)} class="ingredient-amount-button">
|
||||||
{ingredient.amount} {ingredient.unit}
|
{ingredient.amount} {ingredient.unit}
|
||||||
</button>
|
</button>
|
||||||
<button class="force_wrap ingredient-name-button" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)}>
|
<button type="button" class="force_wrap ingredient-name-button" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)}>
|
||||||
{@html ingredient.name}
|
{@html ingredient.name}
|
||||||
</button>
|
</button>
|
||||||
<div class=mod_icons><button class="action_button button_subtle" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)} aria-label={t[lang].editIngredientAria}>
|
<div class=mod_icons><button type="button" class="action_button button_subtle" onclick={() => show_modal_edit_ingredient(list_index, ingredient_index)} aria-label={t[lang].editIngredientAria}>
|
||||||
<Pen fill=var(--nord1) height=1em width=1em></Pen></button>
|
<Pen fill=var(--nord1) height=1em width=1em></Pen></button>
|
||||||
<button class="action_button button_subtle" onclick="{() => remove_ingredient(list_index, ingredient_index)}" aria-label={t[lang].removeIngredientAria}><Cross fill=var(--nord1) height=1em width=1em></Cross></button></div>
|
<button type="button" class="action_button button_subtle" onclick="{() => remove_ingredient(list_index, ingredient_index)}" aria-label={t[lang].removeIngredientAria}><Cross fill=var(--nord1) height=1em width=1em></Cross></button></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Button to insert base recipe -->
|
<!-- Button to insert base recipe -->
|
||||||
<button class="insert-base-recipe-button" onclick={() => openSelector(ingredients.length)}>
|
<button type="button" class="insert-base-recipe-button" onclick={() => openSelector(ingredients.length)}>
|
||||||
<Plus fill="white" style="display: inline; width: 1.5em; height: 1.5em; vertical-align: middle;"></Plus>
|
<Plus fill="white" style="display: inline; width: 1.5em; height: 1.5em; vertical-align: middle;"></Plus>
|
||||||
{t[lang].insertBaseRecipe}
|
{t[lang].insertBaseRecipe}
|
||||||
</button>
|
</button>
|
||||||
@@ -880,7 +880,7 @@ h3{
|
|||||||
<input type="text" placeholder="250..." bind:value={new_ingredient.amount} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
|
<input type="text" placeholder="250..." bind:value={new_ingredient.amount} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
|
||||||
<input type="text" placeholder="mL..." bind:value={new_ingredient.unit} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
|
<input type="text" placeholder="mL..." bind:value={new_ingredient.unit} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
|
||||||
<input type="text" placeholder="Milch..." bind:value={new_ingredient.name} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
|
<input type="text" placeholder="Milch..." bind:value={new_ingredient.name} onkeydown={(event) => do_on_key(event, 'Enter', false, add_new_ingredient)}>
|
||||||
<button onclick={() => add_new_ingredient()} class=action_button>
|
<button type="button" onclick={() => add_new_ingredient()} class=action_button>
|
||||||
<Plus fill=white style="width: 2rem; height: 2rem;"></Plus>
|
<Plus fill=white style="width: 2rem; height: 2rem;"></Plus>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -893,7 +893,7 @@ h3{
|
|||||||
<input type="text" placeholder="250..." bind:value={edit_ingredient.amount} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
<input type="text" placeholder="250..." bind:value={edit_ingredient.amount} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
||||||
<input type="text" placeholder="mL..." bind:value={edit_ingredient.unit} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
<input type="text" placeholder="mL..." bind:value={edit_ingredient.unit} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
||||||
<input type="text" placeholder="Milch..." bind:value={edit_ingredient.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
<input type="text" placeholder="Milch..." bind:value={edit_ingredient.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)}>
|
||||||
<button class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)} onclick={edit_ingredient_and_close_modal}>
|
<button type="button" class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, edit_ingredient_and_close_modal)} onclick={edit_ingredient_and_close_modal}>
|
||||||
<Check fill=white style="width: 2rem; height: 2rem;"></Check>
|
<Check fill=white style="width: 2rem; height: 2rem;"></Check>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -904,7 +904,7 @@ h3{
|
|||||||
<h2>{t[lang].renameCategory}</h2>
|
<h2>{t[lang].renameCategory}</h2>
|
||||||
<div class=heading_wrapper>
|
<div class=heading_wrapper>
|
||||||
<input class=heading type="text" bind:value={edit_heading.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} >
|
<input class=heading type="text" bind:value={edit_heading.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} >
|
||||||
<button class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} onclick={edit_subheading_and_close_modal}>
|
<button type="button" class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_and_close_modal)} onclick={edit_subheading_and_close_modal}>
|
||||||
<Check fill=white style="width:2rem; height:2rem;"></Check>
|
<Check fill=white style="width:2rem; height:2rem;"></Check>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -788,10 +788,10 @@ h3{
|
|||||||
<div class="reference-container">
|
<div class="reference-container">
|
||||||
<div class="reference-header">
|
<div class="reference-header">
|
||||||
<div class="move_buttons_container">
|
<div class="move_buttons_container">
|
||||||
<button onclick={() => update_list_position(list_index, 1)} aria-label={t[lang].moveReferenceUpAria}>
|
<button type="button" onclick={() => update_list_position(list_index, 1)} aria-label={t[lang].moveReferenceUpAria}>
|
||||||
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => update_list_position(list_index, -1)} aria-label={t[lang].moveReferenceDownAria}>
|
<button type="button" onclick={() => update_list_position(list_index, -1)} aria-label={t[lang].moveReferenceDownAria}>
|
||||||
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
<svg class="button_arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -799,7 +799,7 @@ h3{
|
|||||||
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
|
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
|
||||||
</div>
|
</div>
|
||||||
<div class="mod_icons">
|
<div class="mod_icons">
|
||||||
<button class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => removeReference(list_index)} aria-label={t[lang].removeReferenceAria}>
|
||||||
<Cross fill="var(--nord11)"></Cross>
|
<Cross fill="var(--nord11)"></Cross>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -815,14 +815,14 @@ h3{
|
|||||||
<div class="move_buttons_container step_move_buttons">
|
<div class="move_buttons_container step_move_buttons">
|
||||||
<!-- Empty for consistency -->
|
<!-- Empty for consistency -->
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => editStepFromReference(list_index, 'before', step_index)} class="step-button" style="flex-grow: 1;">
|
<button type="button" onclick={() => editStepFromReference(list_index, 'before', step_index)} class="step-button" style="flex-grow: 1;">
|
||||||
{@html step}
|
{@html step}
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<button class="action_button button_subtle" onclick={() => editStepFromReference(list_index, 'before', step_index)} aria-label={t[lang].editStepAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => editStepFromReference(list_index, 'before', step_index)} aria-label={t[lang].editStepAria}>
|
||||||
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
|
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
|
||||||
</button>
|
</button>
|
||||||
<button class="action_button button_subtle" onclick={() => removeStepFromReference(list_index, 'before', step_index)} aria-label={t[lang].removeStepAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => removeStepFromReference(list_index, 'before', step_index)} aria-label={t[lang].removeStepAria}>
|
||||||
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
|
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -831,7 +831,7 @@ h3{
|
|||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'before')}>
|
<button type="button" class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'before')}>
|
||||||
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addStepBefore}
|
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addStepBefore}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -841,7 +841,7 @@ h3{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Steps after base recipe -->
|
<!-- Steps after base recipe -->
|
||||||
<button class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'after')}>
|
<button type="button" class="action_button button_subtle add-to-reference-button" onclick={() => openAddToReferenceModal(list_index, 'after')}>
|
||||||
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addStepAfter}
|
<Plus fill="var(--nord9)" height="1em" width="1em"></Plus> {t[lang].addStepAfter}
|
||||||
</button>
|
</button>
|
||||||
{#if list.stepsAfter && list.stepsAfter.length > 0}
|
{#if list.stepsAfter && list.stepsAfter.length > 0}
|
||||||
@@ -853,14 +853,14 @@ h3{
|
|||||||
<div class="move_buttons_container step_move_buttons">
|
<div class="move_buttons_container step_move_buttons">
|
||||||
<!-- Empty for consistency -->
|
<!-- Empty for consistency -->
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => editStepFromReference(list_index, 'after', step_index)} class="step-button" style="flex-grow: 1;">
|
<button type="button" onclick={() => editStepFromReference(list_index, 'after', step_index)} class="step-button" style="flex-grow: 1;">
|
||||||
{@html step}
|
{@html step}
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<button class="action_button button_subtle" onclick={() => editStepFromReference(list_index, 'after', step_index)} aria-label={t[lang].editStepAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => editStepFromReference(list_index, 'after', step_index)} aria-label={t[lang].editStepAria}>
|
||||||
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
|
<Pen fill="var(--nord6)" height="1em" width="1em"></Pen>
|
||||||
</button>
|
</button>
|
||||||
<button class="action_button button_subtle" onclick={() => removeStepFromReference(list_index, 'after', step_index)} aria-label={t[lang].removeStepAria}>
|
<button type="button" class="action_button button_subtle" onclick={() => removeStepFromReference(list_index, 'after', step_index)} aria-label={t[lang].removeStepAria}>
|
||||||
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
|
<Cross fill="var(--nord6)" height="1em" width="1em"></Cross>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -874,23 +874,23 @@ h3{
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<h3>
|
<h3>
|
||||||
<div class=move_buttons_container>
|
<div class=move_buttons_container>
|
||||||
<button onclick="{() => update_list_position(list_index, 1)}" aria-label={t[lang].moveListUpAria}>
|
<button type="button" onclick="{() => update_list_position(list_index, 1)}" aria-label={t[lang].moveListUpAria}>
|
||||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="{() => update_list_position(list_index, -1)}" aria-label={t[lang].moveListDownAria}>
|
<button type="button" onclick="{() => update_list_position(list_index, -1)}" aria-label={t[lang].moveListDownAria}>
|
||||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => show_modal_edit_subheading_step(list_index)} class="subheading-button">
|
<button type="button" onclick={() => show_modal_edit_subheading_step(list_index)} class="subheading-button">
|
||||||
{#if list.name}
|
{#if list.name}
|
||||||
{list.name}
|
{list.name}
|
||||||
{:else}
|
{:else}
|
||||||
{t[lang].empty}
|
{t[lang].empty}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button class="action_button button_subtle" onclick="{() => show_modal_edit_subheading_step(list_index)}" aria-label={t[lang].editHeading}>
|
<button type="button" class="action_button button_subtle" onclick="{() => show_modal_edit_subheading_step(list_index)}" aria-label={t[lang].editHeading}>
|
||||||
<Pen fill=var(--nord1)></Pen> </button>
|
<Pen fill=var(--nord1)></Pen> </button>
|
||||||
<button class="action_button button_subtle" onclick="{() => remove_list(list_index)}" aria-label={t[lang].removeList}>
|
<button type="button" class="action_button button_subtle" onclick="{() => remove_list(list_index)}" aria-label={t[lang].removeList}>
|
||||||
<Cross fill=var(--nord1)></Cross>
|
<Cross fill=var(--nord1)></Cross>
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -899,21 +899,21 @@ h3{
|
|||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<li>
|
<li>
|
||||||
<div class="move_buttons_container step_move_buttons">
|
<div class="move_buttons_container step_move_buttons">
|
||||||
<button onclick="{() => update_step_position(list_index, step_index, 1)}" aria-label={t[lang].moveUpAria}>
|
<button type="button" onclick="{() => update_step_position(list_index, step_index, 1)}" aria-label={t[lang].moveUpAria}>
|
||||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6 1.41 1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="{() => update_step_position(list_index, step_index, -1)}" aria-label={t[lang].moveDownAria}>
|
<button type="button" onclick="{() => update_step_position(list_index, step_index, -1)}" aria-label={t[lang].moveDownAria}>
|
||||||
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
<svg class=button_arrow xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16px" height="16px"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button onclick={() => show_modal_edit_step(list_index, step_index)} class="step-button">
|
<button type="button" onclick={() => show_modal_edit_step(list_index, step_index)} class="step-button">
|
||||||
{@html step}
|
{@html step}
|
||||||
</button>
|
</button>
|
||||||
<div><button class="action_button button_subtle" onclick={() => show_modal_edit_step(list_index, step_index)} aria-label={t[lang].editStepAria}>
|
<div><button type="button" class="action_button button_subtle" onclick={() => show_modal_edit_step(list_index, step_index)} aria-label={t[lang].editStepAria}>
|
||||||
<Pen fill=var(--nord1)></Pen>
|
<Pen fill=var(--nord1)></Pen>
|
||||||
</button>
|
</button>
|
||||||
<button class="action_button button_subtle" onclick="{() => remove_step(list_index, step_index)}" aria-label={t[lang].removeStepAria}>
|
<button type="button" class="action_button button_subtle" onclick="{() => remove_step(list_index, step_index)}" aria-label={t[lang].removeStepAria}>
|
||||||
<Cross fill=var(--nord1)></Cross>
|
<Cross fill=var(--nord1)></Cross>
|
||||||
</button>
|
</button>
|
||||||
</div></div>
|
</div></div>
|
||||||
@@ -924,7 +924,7 @@ h3{
|
|||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Button to insert base recipe -->
|
<!-- Button to insert base recipe -->
|
||||||
<button class="insert-base-recipe-button" onclick={() => openSelector(instructions.length)}>
|
<button type="button" class="insert-base-recipe-button" onclick={() => openSelector(instructions.length)}>
|
||||||
<Plus fill="white" style="display: inline; width: 1.5em; height: 1.5em; vertical-align: middle;"></Plus>
|
<Plus fill="white" style="display: inline; width: 1.5em; height: 1.5em; vertical-align: middle;"></Plus>
|
||||||
{t[lang].insertBaseRecipe}
|
{t[lang].insertBaseRecipe}
|
||||||
</button>
|
</button>
|
||||||
@@ -934,7 +934,7 @@ h3{
|
|||||||
<input class=category type="text" bind:value={new_step.name} placeholder={t[lang].categoryOptional} onkeydown={(event) => do_on_key(event, 'Enter', false , add_new_step)} >
|
<input class=category type="text" bind:value={new_step.name} placeholder={t[lang].categoryOptional} onkeydown={(event) => do_on_key(event, 'Enter', false , add_new_step)} >
|
||||||
<div class=add_step>
|
<div class=add_step>
|
||||||
<p id=step contenteditable onfocus='{clear_step}' onblur={add_placeholder} bind:innerText={new_step.step} onkeydown={(event) => do_on_key(event, 'Enter', true , add_new_step)}></p>
|
<p id=step contenteditable onfocus='{clear_step}' onblur={add_placeholder} bind:innerText={new_step.step} onkeydown={(event) => do_on_key(event, 'Enter', true , add_new_step)}></p>
|
||||||
<button onclick={() => add_new_step()} class=action_button>
|
<button type="button" onclick={() => add_new_step()} class=action_button>
|
||||||
<Plus fill=white style="height: 2rem; width: 2rem"></Plus>
|
<Plus fill=white style="height: 2rem; width: 2rem"></Plus>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -946,7 +946,7 @@ h3{
|
|||||||
<input class=category type="text" bind:value={edit_step.name} placeholder={t[lang].subcategoryOptional} onkeydown={(event) => do_on_key(event, 'Enter', false , edit_step_and_close_modal)}>
|
<input class=category type="text" bind:value={edit_step.name} placeholder={t[lang].subcategoryOptional} onkeydown={(event) => do_on_key(event, 'Enter', false , edit_step_and_close_modal)}>
|
||||||
<div class=add_step>
|
<div class=add_step>
|
||||||
<p id=step contenteditable bind:innerText={edit_step.step} onkeydown={(event) => do_on_key(event, 'Enter', true , edit_step_and_close_modal)}></p>
|
<p id=step contenteditable bind:innerText={edit_step.step} onkeydown={(event) => do_on_key(event, 'Enter', true , edit_step_and_close_modal)}></p>
|
||||||
<button class=action_button onclick="{() => edit_step_and_close_modal()}" >
|
<button type="button" class=action_button onclick="{() => edit_step_and_close_modal()}" >
|
||||||
<Check fill=white style="height: 2rem; width: 2rem"></Check>
|
<Check fill=white style="height: 2rem; width: 2rem"></Check>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -957,7 +957,7 @@ h3{
|
|||||||
<h2>{t[lang].renameCategory}</h2>
|
<h2>{t[lang].renameCategory}</h2>
|
||||||
<div class=heading_wrapper>
|
<div class=heading_wrapper>
|
||||||
<input class="heading" type="text" bind:value={edit_heading.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_steps_and_close_modal)}>
|
<input class="heading" type="text" bind:value={edit_heading.name} onkeydown={(event) => do_on_key(event, 'Enter', false, edit_subheading_steps_and_close_modal)}>
|
||||||
<button onclick={edit_subheading_steps_and_close_modal} class=action_button>
|
<button type="button" onclick={edit_subheading_steps_and_close_modal} class=action_button>
|
||||||
<Check fill=white style="height: 2rem; width: 2rem"></Check>
|
<Check fill=white style="height: 2rem; width: 2rem"></Check>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
// Helper function to initialize images array for English translation
|
// Helper function to initialize images array for English translation
|
||||||
function initializeImagesArray(germanImages: any[]): any[] {
|
function initializeImagesArray(germanImages: any[]): any[] {
|
||||||
if (!germanImages || germanImages.length === 0) return [];
|
if (!germanImages || germanImages.length === 0) return [];
|
||||||
return germanImages.map(() => ({
|
return germanImages.map((img) => ({
|
||||||
|
mediapath: img.mediapath || '',
|
||||||
alt: '',
|
alt: '',
|
||||||
caption: ''
|
caption: ''
|
||||||
}));
|
}));
|
||||||
@@ -64,6 +65,14 @@
|
|||||||
let untranslatedBaseRecipes = $state<{ shortName: string, name: string }[]>([]);
|
let untranslatedBaseRecipes = $state<{ shortName: string, name: string }[]>([]);
|
||||||
let checkingBaseRecipes = $state(false);
|
let checkingBaseRecipes = $state(false);
|
||||||
|
|
||||||
|
// Ensure images array is properly synced when germanData changes
|
||||||
|
$effect(() => {
|
||||||
|
if (germanData?.images && (!editableEnglish.images || editableEnglish.images.length !== germanData.images.length)) {
|
||||||
|
// Re-initialize images array to match germanData length
|
||||||
|
editableEnglish.images = initializeImagesArray(germanData.images);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Sync base recipe references from German to English
|
// Sync base recipe references from German to English
|
||||||
async function syncBaseRecipeReferences() {
|
async function syncBaseRecipeReferences() {
|
||||||
if (!germanData) return;
|
if (!germanData) return;
|
||||||
@@ -778,68 +787,70 @@ button:disabled {
|
|||||||
<div class="field-section" style="background-color: var(--nord13); padding: 1rem; border-radius: 5px; margin-top: 1.5rem;">
|
<div class="field-section" style="background-color: var(--nord13); padding: 1rem; border-radius: 5px; margin-top: 1.5rem;">
|
||||||
<h4 style="margin-top: 0; color: var(--nord0);">🖼️ Images - English Alt Texts & Captions</h4>
|
<h4 style="margin-top: 0; color: var(--nord0);">🖼️ Images - English Alt Texts & Captions</h4>
|
||||||
{#each germanData.images as germanImage, i}
|
{#each germanData.images as germanImage, i}
|
||||||
<div style="background-color: white; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; border: 2px solid var(--nord9);">
|
{#if editableEnglish.images && editableEnglish.images[i]}
|
||||||
<div style="display: flex; gap: 1rem; align-items: start;">
|
<div style="background-color: white; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; border: 2px solid var(--nord9);">
|
||||||
<img
|
<div style="display: flex; gap: 1rem; align-items: start;">
|
||||||
src="https://bocken.org/static/rezepte/thumb/{germanImage.mediapath}"
|
<img
|
||||||
alt={germanImage.alt || 'Recipe image'}
|
src="https://bocken.org/static/rezepte/thumb/{germanImage.mediapath}"
|
||||||
style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;"
|
alt={germanImage.alt || 'Recipe image'}
|
||||||
/>
|
style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;"
|
||||||
<div style="flex: 1;">
|
/>
|
||||||
<p style="margin: 0 0 0.5rem 0; font-size: 0.85rem; color: var(--nord3);"><strong>Image {i + 1}:</strong> {germanImage.mediapath}</p>
|
<div style="flex: 1;">
|
||||||
|
<p style="margin: 0 0 0.5rem 0; font-size: 0.85rem; color: var(--nord3);"><strong>Image {i + 1}:</strong> {germanImage.mediapath}</p>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.75rem;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.75rem;">
|
||||||
<div>
|
<div>
|
||||||
<label for="german-alt-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇩🇪 German Alt-Text:</label>
|
<label for="german-alt-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇩🇪 German Alt-Text:</label>
|
||||||
<input
|
<input
|
||||||
id="german-alt-{i}"
|
id="german-alt-{i}"
|
||||||
type="text"
|
type="text"
|
||||||
value={germanImage.alt || ''}
|
value={germanImage.alt || ''}
|
||||||
disabled
|
disabled
|
||||||
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord4); border-radius: 3px; background-color: var(--nord5); color: var(--nord2); font-size: 0.85rem;"
|
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord4); border-radius: 3px; background-color: var(--nord5); color: var(--nord2); font-size: 0.85rem;"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="english-alt-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇬🇧 English Alt-Text:</label>
|
||||||
|
<input
|
||||||
|
id="english-alt-{i}"
|
||||||
|
type="text"
|
||||||
|
bind:value={editableEnglish.images[i].alt}
|
||||||
|
placeholder="English image description for screen readers"
|
||||||
|
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord8); border-radius: 3px; font-size: 0.85rem;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="english-alt-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇬🇧 English Alt-Text:</label>
|
|
||||||
<input
|
|
||||||
id="english-alt-{i}"
|
|
||||||
type="text"
|
|
||||||
bind:value={editableEnglish.images[i].alt}
|
|
||||||
placeholder="English image description for screen readers"
|
|
||||||
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord8); border-radius: 3px; font-size: 0.85rem;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||||
<div>
|
<div>
|
||||||
<label for="german-caption-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇩🇪 German Caption:</label>
|
<label for="german-caption-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇩🇪 German Caption:</label>
|
||||||
<input
|
<input
|
||||||
id="german-caption-{i}"
|
id="german-caption-{i}"
|
||||||
type="text"
|
type="text"
|
||||||
value={germanImage.caption || ''}
|
value={germanImage.caption || ''}
|
||||||
disabled
|
disabled
|
||||||
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord4); border-radius: 3px; background-color: var(--nord5); color: var(--nord2); font-size: 0.85rem;"
|
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord4); border-radius: 3px; background-color: var(--nord5); color: var(--nord2); font-size: 0.85rem;"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="english-caption-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇬🇧 English Caption:</label>
|
||||||
|
<input
|
||||||
|
id="english-caption-{i}"
|
||||||
|
type="text"
|
||||||
|
bind:value={editableEnglish.images[i].caption}
|
||||||
|
placeholder="English caption (optional)"
|
||||||
|
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord8); border-radius: 3px; font-size: 0.85rem;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label for="english-caption-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇬🇧 English Caption:</label>
|
|
||||||
<input
|
|
||||||
id="english-caption-{i}"
|
|
||||||
type="text"
|
|
||||||
bind:value={editableEnglish.images[i].caption}
|
|
||||||
placeholder="English caption (optional)"
|
|
||||||
style="width: 100%; padding: 0.4rem; border: 1px solid var(--nord8); border-radius: 3px; font-size: 0.85rem;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top: 0.75rem;">
|
<div style="margin-top: 0.75rem;">
|
||||||
<GenerateAltTextButton shortName={germanData.short_name} imageIndex={i} />
|
<GenerateAltTextButton shortName={germanData.short_name} imageIndex={i} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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
|
// Add is German-only - redirect to German version
|
||||||
if (params.recipeLang === 'recipes') {
|
if (params.recipeLang === 'recipes') {
|
||||||
throw redirect(301, '/rezepte/add');
|
throw redirect(301, '/rezepte/add');
|
||||||
@@ -8,6 +17,105 @@ export async function load({locals, params}) {
|
|||||||
|
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
return {
|
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">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
import Check from '$lib/assets/icons/Check.svelte';
|
import Check from '$lib/assets/icons/Check.svelte';
|
||||||
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
||||||
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
||||||
import '$lib/css/action_button.css'
|
import CardAdd from '$lib/components/CardAdd.svelte';
|
||||||
import '$lib/css/nordtheme.css'
|
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 { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
let addendum = ""
|
|
||||||
|
// Recipe data state
|
||||||
|
let preamble = $state("");
|
||||||
|
let addendum = $state("");
|
||||||
|
let image_preview_url = $state("");
|
||||||
|
let uploaded_image_filename = $state("");
|
||||||
|
|
||||||
// Translation workflow state
|
// Translation workflow state
|
||||||
let showTranslationWorkflow = false;
|
let showTranslationWorkflow = $state(false);
|
||||||
let translationData: any = null;
|
let translationData: any = $state(null);
|
||||||
|
|
||||||
|
// Season store
|
||||||
import { season } from '$lib/js/season_store';
|
import { season } from '$lib/js/season_store';
|
||||||
import { portions } from '$lib/js/portions_store';
|
import { portions } from '$lib/js/portions_store';
|
||||||
import { img } from '$lib/js/img_store';
|
|
||||||
season.update(() => [])
|
season.update(() => []);
|
||||||
let season_local
|
let season_local = $state<number[]>([]);
|
||||||
season.subscribe((s) => {
|
season.subscribe((s) => {
|
||||||
season_local = s
|
season_local = s;
|
||||||
});
|
});
|
||||||
let portions_local
|
|
||||||
portions.update(() => "")
|
let portions_local = $state("");
|
||||||
|
portions.update(() => "");
|
||||||
portions.subscribe((p) => {
|
portions.subscribe((p) => {
|
||||||
portions_local = p});
|
portions_local = p;
|
||||||
let img_local
|
});
|
||||||
img.update(() => "")
|
|
||||||
img.subscribe((i) => {
|
|
||||||
img_local = i});
|
|
||||||
|
|
||||||
|
let card_data = $state({
|
||||||
|
|
||||||
export let card_data ={
|
|
||||||
icon: "",
|
icon: "",
|
||||||
category: "",
|
category: "",
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
tags: [],
|
tags: [] as string[],
|
||||||
}
|
});
|
||||||
export let add_info ={
|
|
||||||
|
let add_info = $state({
|
||||||
preparation: "",
|
preparation: "",
|
||||||
fermentation: {
|
fermentation: {
|
||||||
bulk: "",
|
bulk: "",
|
||||||
@@ -51,70 +59,43 @@
|
|||||||
},
|
},
|
||||||
total_time: "",
|
total_time: "",
|
||||||
cooking: "",
|
cooking: "",
|
||||||
}
|
});
|
||||||
|
|
||||||
let images = []
|
let short_name = $state("");
|
||||||
let short_name = ""
|
let isBaseRecipe = $state(false);
|
||||||
let datecreated = new Date()
|
let ingredients = $state<any[]>([]);
|
||||||
let datemodified = datecreated
|
let instructions = $state<any[]>([]);
|
||||||
let isBaseRecipe = false
|
|
||||||
|
|
||||||
import type { PageData } from './$types';
|
// Form submission state
|
||||||
import CardAdd from '$lib/components/CardAdd.svelte';
|
let submitting = $state(false);
|
||||||
|
let formElement: HTMLFormElement;
|
||||||
|
|
||||||
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
|
// Get season data from checkboxes
|
||||||
export let ingredients = []
|
function get_season(): number[] {
|
||||||
|
const season: number[] = [];
|
||||||
import CreateStepList from '$lib/components/CreateStepList.svelte';
|
|
||||||
export let instructions = []
|
|
||||||
|
|
||||||
|
|
||||||
function get_season(){
|
|
||||||
let season = []
|
|
||||||
const el = document.getElementById("labels");
|
const el = document.getElementById("labels");
|
||||||
for(var i = 0; i < el.children.length; i++){
|
if (!el) return season;
|
||||||
if(el.children[i].children[0].children[0].checked){
|
|
||||||
season.push(i+1)
|
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
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upload_img(){
|
// Prepare German recipe data
|
||||||
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
|
|
||||||
function getGermanRecipeData() {
|
function getGermanRecipeData() {
|
||||||
return {
|
return {
|
||||||
...card_data,
|
...card_data,
|
||||||
...add_info,
|
...add_info,
|
||||||
images: [{mediapath: short_name.trim() + '.webp', alt: "", caption: ""}],
|
images: uploaded_image_filename ? [{ mediapath: uploaded_image_filename, alt: "", caption: "" }] : [],
|
||||||
season: season_local,
|
season: season_local,
|
||||||
short_name : short_name.trim(),
|
short_name: short_name.trim(),
|
||||||
portions: portions_local,
|
portions: portions_local,
|
||||||
datecreated,
|
datecreated: new Date(),
|
||||||
datemodified,
|
datemodified: new Date(),
|
||||||
instructions,
|
instructions,
|
||||||
ingredients,
|
ingredients,
|
||||||
preamble,
|
preamble,
|
||||||
@@ -125,7 +106,7 @@
|
|||||||
|
|
||||||
// Show translation workflow before submission
|
// Show translation workflow before submission
|
||||||
function prepareSubmit() {
|
function prepareSubmit() {
|
||||||
// Validate required fields
|
// Client-side validation
|
||||||
if (!short_name.trim()) {
|
if (!short_name.trim()) {
|
||||||
alert('Bitte geben Sie einen Kurznamen ein');
|
alert('Bitte geben Sie einen Kurznamen ein');
|
||||||
return;
|
return;
|
||||||
@@ -142,16 +123,24 @@
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle translation approval
|
// Handle translation approval - populate form and submit
|
||||||
function handleTranslationApproved(event: CustomEvent) {
|
function handleTranslationApproved(event: CustomEvent) {
|
||||||
translationData = event.detail.translatedRecipe;
|
translationData = event.detail.translatedRecipe;
|
||||||
doPost();
|
|
||||||
|
// Submit the form programmatically
|
||||||
|
if (formElement) {
|
||||||
|
formElement.requestSubmit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle translation skipped
|
// Handle translation skipped - submit without translation
|
||||||
function handleTranslationSkipped() {
|
function handleTranslationSkipped() {
|
||||||
translationData = null;
|
translationData = null;
|
||||||
doPost();
|
|
||||||
|
// Submit the form programmatically
|
||||||
|
if (formElement) {
|
||||||
|
formElement.requestSubmit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle translation cancelled
|
// Handle translation cancelled
|
||||||
@@ -160,51 +149,16 @@
|
|||||||
translationData = null;
|
translationData = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actually submit the recipe
|
// Display form errors if any
|
||||||
async function doPost () {
|
$effect(() => {
|
||||||
upload_img()
|
if (form?.error) {
|
||||||
console.log(add_info.total_time)
|
alert(`Fehler: ${form.error}`);
|
||||||
|
|
||||||
const recipeData = getGermanRecipeData();
|
|
||||||
|
|
||||||
// Add translations if available
|
|
||||||
if (translationData) {
|
|
||||||
recipeData.translations = {
|
|
||||||
en: translationData
|
|
||||||
};
|
|
||||||
recipeData.translationMetadata = {
|
|
||||||
lastModifiedGerman: new Date(),
|
|
||||||
fieldsModifiedSinceTranslation: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
input{
|
input {
|
||||||
display: block;
|
display: block;
|
||||||
border: unset;
|
border: unset;
|
||||||
margin: 1rem auto;
|
margin: 1rem auto;
|
||||||
@@ -213,14 +167,12 @@ input{
|
|||||||
background-color: var(--nord4);
|
background-color: var(--nord4);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
transition: 100ms;
|
transition: 100ms;
|
||||||
|
|
||||||
}
|
}
|
||||||
input:hover,
|
input:hover,
|
||||||
input:focus-visible
|
input:focus-visible {
|
||||||
{
|
|
||||||
scale: 1.05 1.05;
|
scale: 1.05 1.05;
|
||||||
}
|
}
|
||||||
.list_wrapper{
|
.list_wrapper {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -228,22 +180,22 @@ input:focus-visible
|
|||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 700px){
|
@media screen and (max-width: 700px) {
|
||||||
.list_wrapper{
|
.list_wrapper {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h1{
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
.title_container{
|
.title_container {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
.title{
|
.title {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(800px, 80vw);
|
width: min(800px, 80vw);
|
||||||
margin-block: 2rem;
|
margin-block: 2rem;
|
||||||
@@ -251,7 +203,7 @@ h1{
|
|||||||
background-color: var(--nord6);
|
background-color: var(--nord6);
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
}
|
}
|
||||||
.title p{
|
.title p {
|
||||||
border: 2px solid var(--nord1);
|
border: 2px solid var(--nord1);
|
||||||
border-radius: 10000px;
|
border-radius: 10000px;
|
||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
@@ -259,10 +211,10 @@ h1{
|
|||||||
transition: 200ms;
|
transition: 200ms;
|
||||||
}
|
}
|
||||||
.title p:hover,
|
.title p:hover,
|
||||||
.title p:focus-within{
|
.title p:focus-within {
|
||||||
scale: 1.02 1.02;
|
scale: 1.02 1.02;
|
||||||
}
|
}
|
||||||
.addendum{
|
.addendum {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
@@ -272,23 +224,22 @@ h1{
|
|||||||
transition: 100ms;
|
transition: 100ms;
|
||||||
}
|
}
|
||||||
.addendum:hover,
|
.addendum:hover,
|
||||||
.addendum:focus-within
|
.addendum:focus-within {
|
||||||
{
|
|
||||||
scale: 1.02 1.02;
|
scale: 1.02 1.02;
|
||||||
}
|
}
|
||||||
.addendum_wrapper{
|
.addendum_wrapper {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
h3{
|
h3 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
button.action_button{
|
button.action_button {
|
||||||
animation: unset !important;
|
animation: unset !important;
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.submit_buttons{
|
.submit_buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
@@ -297,17 +248,27 @@ button.action_button{
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
.submit_buttons p{
|
.submit_buttons p {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
padding-right: 0.5em;
|
padding-right: 0.5em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark){
|
@media (prefers-color-scheme: dark) {
|
||||||
.title{
|
.title {
|
||||||
background-color: var(--nord6-dark);
|
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>
|
</style>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Rezept erstellen</title>
|
<title>Rezept erstellen</title>
|
||||||
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
|
<meta name="description" content="Hier können neue Rezepte hinzugefügt werden" />
|
||||||
@@ -315,57 +276,119 @@ button.action_button{
|
|||||||
|
|
||||||
<h1>Rezept erstellen</h1>
|
<h1>Rezept erstellen</h1>
|
||||||
|
|
||||||
<CardAdd {card_data}></CardAdd>
|
{#if form?.error}
|
||||||
|
<div class="error-message">
|
||||||
<h3>Kurzname (für URL):</h3>
|
<strong>Fehler:</strong> {form.error}
|
||||||
<input bind:value={short_name} placeholder="Kurzname"/>
|
</div>
|
||||||
|
|
||||||
<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}
|
{/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}
|
{#if showTranslationWorkflow}
|
||||||
<div id="translation-section">
|
<div id="translation-section">
|
||||||
<TranslationApproval
|
<TranslationApproval
|
||||||
germanData={getGermanRecipeData()}
|
germanData={getGermanRecipeData()}
|
||||||
onapproved={handleTranslationApproved}
|
onapproved={handleTranslationApproved}
|
||||||
onskipped={handleTranslationSkipped}
|
onskipped={handleTranslationSkipped}
|
||||||
oncancelled={handleTranslationCancelled}
|
oncancelled={handleTranslationCancelled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import type { PageServerLoad } from "./$types";
|
import type { Actions, PageServerLoad } from "./$types";
|
||||||
import { redirect } from "@sveltejs/kit";
|
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}) => {
|
export const load: PageServerLoad = async ({ fetch, params, locals}) => {
|
||||||
// Edit is German-only - redirect to German version
|
// Edit is German-only - redirect to German version
|
||||||
@@ -14,11 +27,169 @@ export const load: PageServerLoad = async ({ fetch, params, locals}) => {
|
|||||||
throw redirect(301, '/rezepte');
|
throw redirect(301, '/rezepte');
|
||||||
}
|
}
|
||||||
|
|
||||||
let current_month = new Date().getMonth() + 1
|
|
||||||
const apiRes = await fetch(`/api/rezepte/items/${params.name}`);
|
const apiRes = await fetch(`/api/rezepte/items/${params.name}`);
|
||||||
const recipe = await apiRes.json();
|
const recipe = await apiRes.json();
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
return {recipe: recipe,
|
return {
|
||||||
user: session?.user
|
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">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
import Check from '$lib/assets/icons/Check.svelte';
|
import Check from '$lib/assets/icons/Check.svelte';
|
||||||
import Cross from '$lib/assets/icons/Cross.svelte';
|
import Cross from '$lib/assets/icons/Cross.svelte';
|
||||||
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
|
||||||
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
|
||||||
import GenerateAltTextButton from '$lib/components/GenerateAltTextButton.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 EditRecipeNote from '$lib/components/EditRecipeNote.svelte';
|
||||||
import type { PageData } from './$types';
|
|
||||||
import CardAdd from '$lib/components/CardAdd.svelte';
|
import CardAdd from '$lib/components/CardAdd.svelte';
|
||||||
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
|
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
|
||||||
import CreateStepList from '$lib/components/CreateStepList.svelte';
|
import CreateStepList from '$lib/components/CreateStepList.svelte';
|
||||||
import { season } from '$lib/js/season_store';
|
import { season } from '$lib/js/season_store';
|
||||||
import { portions } from '$lib/js/portions_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);
|
// Recipe data state
|
||||||
let addendum = $state(data.recipe.addendum);
|
let preamble = $state(data.recipe.preamble || "");
|
||||||
let image_preview_url = $state("https://bocken.org/static/rezepte/thumb/" + (data.recipe.images?.[0]?.mediapath || `${data.recipe.short_name}.webp`));
|
let addendum = $state(data.recipe.addendum || "");
|
||||||
let note = $state(data.recipe.note);
|
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
|
// Translation workflow state
|
||||||
let showTranslationWorkflow = $state(false);
|
let showTranslationWorkflow = $state(false);
|
||||||
@@ -30,90 +34,102 @@
|
|||||||
|
|
||||||
// Store original recipe data for change detection
|
// Store original recipe data for change detection
|
||||||
const originalRecipe = JSON.parse(JSON.stringify(data.recipe));
|
const originalRecipe = JSON.parse(JSON.stringify(data.recipe));
|
||||||
|
const old_short_name = data.recipe.short_name;
|
||||||
|
|
||||||
portions.update(() => data.recipe.portions);
|
// Season and portions stores
|
||||||
let portions_local = $state<any>(data.recipe.portions);
|
portions.update(() => data.recipe.portions || "");
|
||||||
|
let portions_local = $state<string>(data.recipe.portions || "");
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
portions.subscribe((p) => {
|
portions.subscribe((p) => {
|
||||||
portions_local = p;
|
portions_local = p;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
season.update(() => data.recipe.season);
|
season.update(() => data.recipe.season || []);
|
||||||
let season_local = $state<any>(data.recipe.season);
|
let season_local = $state<number[]>(data.recipe.season || []);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
season.subscribe((s) => {
|
season.subscribe((s) => {
|
||||||
season_local = 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({
|
let card_data = $state({
|
||||||
icon: data.recipe.icon,
|
icon: data.recipe.icon || "",
|
||||||
category: data.recipe.category,
|
category: data.recipe.category || "",
|
||||||
name: data.recipe.name,
|
name: data.recipe.name || "",
|
||||||
description: data.recipe.description,
|
description: data.recipe.description || "",
|
||||||
tags: data.recipe.tags,
|
tags: data.recipe.tags || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
let add_info = $state({
|
let add_info = $state({
|
||||||
preparation: data.recipe.preparation,
|
preparation: data.recipe.preparation || "",
|
||||||
fermentation: {
|
fermentation: {
|
||||||
bulk: data.recipe.fermentation.bulk,
|
bulk: data.recipe.fermentation?.bulk || "",
|
||||||
final: data.recipe.fermentation.final,
|
final: data.recipe.fermentation?.final || "",
|
||||||
},
|
},
|
||||||
baking: {
|
baking: {
|
||||||
length: data.recipe.baking.length,
|
length: data.recipe.baking?.length || "",
|
||||||
temperature: data.recipe.baking.temperature,
|
temperature: data.recipe.baking?.temperature || "",
|
||||||
mode: data.recipe.baking.mode,
|
mode: data.recipe.baking?.mode || "",
|
||||||
},
|
},
|
||||||
total_time: data.recipe.total_time,
|
total_time: data.recipe.total_time || "",
|
||||||
cooking: data.recipe.cooking,
|
cooking: data.recipe.cooking || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
let images = $state(data.recipe.images);
|
let images = $state(data.recipe.images || []);
|
||||||
|
let short_name = $state(data.recipe.short_name || "");
|
||||||
let short_name = $state(data.recipe.short_name);
|
|
||||||
let datecreated = $state(data.recipe.datecreated);
|
let datecreated = $state(data.recipe.datecreated);
|
||||||
let datemodified = $state(new Date());
|
let datemodified = $state(new Date());
|
||||||
let isBaseRecipe = $state(data.recipe.isBaseRecipe || false);
|
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);
|
// Form submission state
|
||||||
let instructions = $state(data.recipe.instructions);
|
let submitting = $state(false);
|
||||||
|
let formElement: HTMLFormElement;
|
||||||
|
|
||||||
|
// Get season data from checkboxes
|
||||||
function get_season(){
|
function get_season(): number[] {
|
||||||
let season = []
|
const season: number[] = [];
|
||||||
const el = document.getElementById("labels");
|
const el = document.getElementById("labels");
|
||||||
for(var i = 0; i < el.children.length; i++){
|
if (!el) return season;
|
||||||
if(el.children[i].children[0].children[0].checked){
|
|
||||||
season.push(i+1)
|
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
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current German recipe data
|
// Get current German recipe data
|
||||||
function getCurrentRecipeData() {
|
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 {
|
return {
|
||||||
...card_data,
|
...card_data,
|
||||||
...add_info,
|
...add_info,
|
||||||
images,
|
images: recipeImages,
|
||||||
season: season_local,
|
season: season_local,
|
||||||
short_name: short_name.trim(),
|
short_name: short_name.trim(),
|
||||||
datecreated,
|
datecreated,
|
||||||
@@ -129,15 +145,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Detect which fields have changed from the original
|
// Detect which fields have changed from the original
|
||||||
function detectChangedFields() {
|
function detectChangedFields(): string[] {
|
||||||
const current = getCurrentRecipeData();
|
const current = getCurrentRecipeData();
|
||||||
const changed: string[] = [];
|
const changed: string[] = [];
|
||||||
|
|
||||||
const fieldsToCheck = [
|
const fieldsToCheck = [
|
||||||
'name', 'description', 'preamble', 'addendum',
|
'name', 'description', 'preamble', 'addendum', 'note',
|
||||||
'note', 'category', 'tags', 'portions', 'preparation',
|
'category', 'tags', 'portions', 'preparation', 'cooking',
|
||||||
'cooking', 'total_time', 'baking', 'fermentation',
|
'total_time', 'baking', 'fermentation', 'ingredients', 'instructions'
|
||||||
'ingredients', 'instructions'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of fieldsToCheck) {
|
for (const field of fieldsToCheck) {
|
||||||
@@ -153,8 +168,17 @@
|
|||||||
|
|
||||||
// Show translation workflow before submission
|
// Show translation workflow before submission
|
||||||
function prepareSubmit() {
|
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
|
// Only detect changed fields if there's an existing translation
|
||||||
// For first-time translations, changedFields should be empty
|
|
||||||
changedFields = translationData ? detectChangedFields() : [];
|
changedFields = translationData ? detectChangedFields() : [];
|
||||||
showTranslationWorkflow = true;
|
showTranslationWorkflow = true;
|
||||||
|
|
||||||
@@ -166,30 +190,36 @@
|
|||||||
|
|
||||||
// Force full retranslation of entire recipe
|
// Force full retranslation of entire recipe
|
||||||
function forceFullRetranslation() {
|
function forceFullRetranslation() {
|
||||||
// Set changedFields to empty array to trigger full translation
|
|
||||||
changedFields = [];
|
changedFields = [];
|
||||||
showTranslationWorkflow = true;
|
showTranslationWorkflow = true;
|
||||||
|
|
||||||
// Scroll to translation section
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
|
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle translation approval
|
// Handle translation approval - populate form and submit
|
||||||
function handleTranslationApproved(event: CustomEvent) {
|
function handleTranslationApproved(event: CustomEvent) {
|
||||||
translationData = event.detail.translatedRecipe;
|
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() {
|
function handleTranslationSkipped() {
|
||||||
// Mark translation as needing update if fields changed
|
// Mark translation as needing update if fields changed
|
||||||
if (changedFields.length > 0 && translationData) {
|
if (changedFields.length > 0 && translationData) {
|
||||||
translationData.translationStatus = 'needs_update';
|
translationData.translationStatus = 'needs_update';
|
||||||
translationData.changedFields = changedFields;
|
translationData.changedFields = changedFields;
|
||||||
}
|
}
|
||||||
doEdit();
|
|
||||||
|
// Submit the form programmatically
|
||||||
|
if (formElement) {
|
||||||
|
formElement.requestSubmit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle translation cancelled
|
// Handle translation cancelled
|
||||||
@@ -197,438 +227,274 @@
|
|||||||
showTranslationWorkflow = false;
|
showTranslationWorkflow = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doDelete(){
|
// Display form errors if any
|
||||||
// Check for references if this is a base recipe
|
$effect(() => {
|
||||||
const checkRes = await fetch(`/api/rezepte/check-references/${data.recipe._id}`);
|
if (form?.error) {
|
||||||
const checkData = await checkRes.json();
|
alert(`Fehler: ${form.error}`);
|
||||||
|
|
||||||
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?");
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
input{
|
input {
|
||||||
display: block;
|
display: block;
|
||||||
border: unset;
|
border: unset;
|
||||||
margin: 1rem auto;
|
margin: 1rem auto;
|
||||||
padding: 0.5em 1em;
|
padding: 0.5em 1em;
|
||||||
border-radius: 1000px;
|
border-radius: 1000px;
|
||||||
background-color: var(--nord4);
|
background-color: var(--nord4);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
transition: 100ms;
|
transition: 100ms;
|
||||||
|
}
|
||||||
}
|
input:hover,
|
||||||
input:hover,
|
input:focus-visible {
|
||||||
input:focus-visible
|
scale: 1.05 1.05;
|
||||||
{
|
}
|
||||||
scale: 1.05 1.05;
|
.list_wrapper {
|
||||||
}
|
margin-inline: auto;
|
||||||
.list_wrapper{
|
display: flex;
|
||||||
margin-inline: auto;
|
flex-direction: row;
|
||||||
display: flex;
|
max-width: 1000px;
|
||||||
flex-direction: row;
|
gap: 2rem;
|
||||||
max-width: 1000px;
|
justify-content: center;
|
||||||
gap: 2rem;
|
}
|
||||||
justify-content: center;
|
@media screen and (max-width: 700px) {
|
||||||
}
|
.list_wrapper {
|
||||||
@media screen and (max-width: 700px){
|
flex-direction: column;
|
||||||
.list_wrapper{
|
}
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.title_container {
|
||||||
|
max-width: 1000px;
|
||||||
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
}
|
.title {
|
||||||
|
position: relative;
|
||||||
/* Fix button icon visibility in dark mode */
|
width: min(800px, 80vw);
|
||||||
@media (prefers-color-scheme: dark) {
|
margin-block: 2rem;
|
||||||
.list_wrapper :global(svg) {
|
margin-inline: auto;
|
||||||
fill: white !important;
|
background-color: var(--nord6);
|
||||||
|
padding: 1rem 2rem;
|
||||||
}
|
}
|
||||||
.list_wrapper :global(.button_arrow) {
|
.title p {
|
||||||
fill: var(--nord4) !important;
|
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 {
|
||||||
h1{
|
scale: 1.02 1.02;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
.addendum {
|
||||||
.title p{
|
font-size: 1.1rem;
|
||||||
border: 2px solid var(--nord1);
|
max-width: 90%;
|
||||||
border-radius: 10000px;
|
margin-inline: auto;
|
||||||
padding: 0.5em 1em;
|
border: 2px solid var(--nord1);
|
||||||
font-size: 1.1rem;
|
border-radius: 45px;
|
||||||
transition: 200ms;
|
padding: 1em 1em;
|
||||||
}
|
transition: 100ms;
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
:global(.image-management-section) {
|
.addendum:hover,
|
||||||
background-color: var(--nord1) !important;
|
.addendum:focus-within {
|
||||||
|
scale: 1.02 1.02;
|
||||||
}
|
}
|
||||||
:global(.image-item) {
|
.addendum_wrapper {
|
||||||
background-color: var(--nord0) !important;
|
max-width: 1000px;
|
||||||
border-color: var(--nord2) !important;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
:global(.image-item input) {
|
h3 {
|
||||||
background-color: var(--nord2) !important;
|
text-align: center;
|
||||||
color: white !important;
|
}
|
||||||
border-color: var(--nord3) !important;
|
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>
|
</style>
|
||||||
<h1>Rezept editieren</h1>
|
|
||||||
<CardAdd {card_data} {image_preview_url} ></CardAdd>
|
|
||||||
|
|
||||||
{#if images && images.length > 0}
|
<svelte:head>
|
||||||
<div class="image-management-section" style="background-color: var(--nord6); padding: 1.5rem; margin: 2rem auto; max-width: 800px; border-radius: 10px;">
|
<title>Rezept bearbeiten - {data.recipe.name}</title>
|
||||||
<h3 style="margin-top: 0;">🖼️ Bilder & Alt-Texte</h3>
|
<meta name="description" content="Bearbeite das Rezept {data.recipe.name}" />
|
||||||
{#each images as image, i}
|
</svelte:head>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 0.75rem;">
|
<h1>Rezept bearbeiten</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 0.75rem;">
|
{#if form?.error}
|
||||||
<label for="image-caption-{i}" style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.9rem;">Caption (DE):</label>
|
<div class="error-message">
|
||||||
<input
|
<strong>Fehler:</strong> {form.error}
|
||||||
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}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class=title_container>
|
<form
|
||||||
<div class=title>
|
method="POST"
|
||||||
<h4>Eine etwas längere Beschreibung:</h4>
|
bind:this={formElement}
|
||||||
<p bind:innerText={preamble} contenteditable></p>
|
use:enhance={() => {
|
||||||
<div class=tags>
|
submitting = true;
|
||||||
<h4>Saison:</h4>
|
return async ({ update }) => {
|
||||||
<SeasonSelect></SeasonSelect>
|
await update();
|
||||||
<EditRecipeNote><p contenteditable bind:innerText={note}></p></EditRecipeNote>
|
submitting = false;
|
||||||
</div>
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- 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>
|
<!-- Hidden inputs for complex nested data -->
|
||||||
</div>
|
<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>
|
<!-- Translation data (updated after approval or marked needs_update) -->
|
||||||
<div>
|
{#if translationData}
|
||||||
<CreateIngredientList {ingredients}></CreateIngredientList>
|
<input type="hidden" name="translation_json" value={JSON.stringify(translationData)} />
|
||||||
</div>
|
<input type="hidden" name="translation_metadata_json" value={JSON.stringify({
|
||||||
<div>
|
lastModifiedGerman: new Date(),
|
||||||
<CreateStepList {instructions} {add_info}></CreateStepList>
|
fieldsModifiedSinceTranslation: changedFields
|
||||||
</div>
|
})} />
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div class=addendum_wrapper>
|
<CardAdd
|
||||||
<h3>Nachtrag:</h3>
|
bind:card_data
|
||||||
<div class=addendum bind:innerText={addendum} contenteditable></div>
|
bind:image_preview_url
|
||||||
</div>
|
bind:uploaded_image_filename
|
||||||
|
short_name={short_name}
|
||||||
|
/>
|
||||||
|
|
||||||
{#if !showTranslationWorkflow}
|
<h3>Kurzname (für URL):</h3>
|
||||||
<div class=submit_buttons>
|
<input name="short_name" bind:value={short_name} placeholder="Kurzname" required />
|
||||||
<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>
|
<!-- Hidden inputs for card data -->
|
||||||
</div>
|
<input type="hidden" name="name" value={card_data.name} />
|
||||||
{/if}
|
<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}
|
{#if showTranslationWorkflow}
|
||||||
<div id="translation-section">
|
<div id="translation-section">
|
||||||
<TranslationApproval
|
<TranslationApproval
|
||||||
germanData={getCurrentRecipeData()}
|
germanData={getCurrentRecipeData()}
|
||||||
englishData={translationData}
|
englishData={translationData}
|
||||||
oldRecipeData={originalRecipe}
|
changedFields={changedFields}
|
||||||
{changedFields}
|
isEditMode={true}
|
||||||
isEditMode={true}
|
oldRecipeData={originalRecipe}
|
||||||
onapproved={handleTranslationApproved}
|
onapproved={handleTranslationApproved}
|
||||||
onskipped={handleTranslationSkipped}
|
onskipped={handleTranslationSkipped}
|
||||||
oncancelled={handleTranslationCancelled}
|
oncancelled={handleTranslationCancelled}
|
||||||
onforceFullRetranslation={forceFullRetranslation}
|
onforceFullRetranslation={forceFullRetranslation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,66 +1,114 @@
|
|||||||
import path from 'path'
|
import path from 'path';
|
||||||
import type { RequestHandler } from '@sveltejs/kit';
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import { IMAGE_DIR } from '$env/static/private'
|
import { IMAGE_DIR } from '$env/static/private';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { generateImageHashFromBuffer, getHashedFilename } from '$utils/imageHash';
|
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();
|
const auth = await locals.auth();
|
||||||
if (!auth) throw error(401, "Need to be logged in")
|
if (!auth) {
|
||||||
let full_res = new Buffer.from(data.image, 'base64')
|
throw error(401, 'Authentication required to upload images');
|
||||||
|
}
|
||||||
|
|
||||||
// Generate content hash for cache busting
|
try {
|
||||||
const imageHash = generateImageHashFromBuffer(full_res);
|
const formData = await request.formData();
|
||||||
const hashedFilename = getHashedFilename(data.name, imageHash);
|
|
||||||
const unhashedFilename = data.name + '.webp';
|
|
||||||
|
|
||||||
// reduce image size if over 500KB
|
// Extract image file and filename
|
||||||
const MAX_SIZE_KB = 500
|
const image = formData.get('image') as File;
|
||||||
//const metadata = await sharp(full_res).metadata()
|
const name = formData.get('name')?.toString().trim();
|
||||||
////reduce image size if larger than 500KB
|
|
||||||
//if(metadata.size > MAX_SIZE_KB*1000){
|
|
||||||
// full_res = sharp(full_res).
|
|
||||||
// webp( { quality: 70})
|
|
||||||
// .toBuffer()
|
|
||||||
//}
|
|
||||||
|
|
||||||
// Save full size - both hashed and unhashed versions
|
if (!image) {
|
||||||
const fullBuffer = await sharp(full_res)
|
throw error(400, 'No image file provided');
|
||||||
.toFormat('webp')
|
}
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
await sharp(fullBuffer)
|
if (!name) {
|
||||||
.toFile(path.join(IMAGE_DIR, "rezepte", "full", hashedFilename));
|
throw error(400, 'Image name is required');
|
||||||
await sharp(fullBuffer)
|
}
|
||||||
.toFile(path.join(IMAGE_DIR, "rezepte", "full", unhashedFilename));
|
|
||||||
|
|
||||||
// Save thumbnail - both hashed and unhashed versions
|
// Comprehensive security validation
|
||||||
const thumbBuffer = await sharp(full_res)
|
const validationResult = await validateImageFile(image);
|
||||||
.resize({ width: 800})
|
if (!validationResult.valid) {
|
||||||
.toFormat('webp')
|
throw error(400, validationResult.error || 'Invalid image file');
|
||||||
.toBuffer();
|
}
|
||||||
|
|
||||||
await sharp(thumbBuffer)
|
// Convert File to Buffer for processing
|
||||||
.toFile(path.join(IMAGE_DIR, "rezepte", "thumb", hashedFilename));
|
const arrayBuffer = await image.arrayBuffer();
|
||||||
await sharp(thumbBuffer)
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
.toFile(path.join(IMAGE_DIR, "rezepte", "thumb", unhashedFilename));
|
|
||||||
|
|
||||||
// Save placeholder - both hashed and unhashed versions
|
// Generate content hash for cache busting
|
||||||
const placeholderBuffer = await sharp(full_res)
|
const imageHash = generateImageHashFromBuffer(buffer);
|
||||||
.resize({ width: 20})
|
const hashedFilename = getHashedFilename(name, imageHash);
|
||||||
.toFormat('webp')
|
const unhashedFilename = name + '.webp';
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
await sharp(placeholderBuffer)
|
// Process image with Sharp - convert to WebP format
|
||||||
.toFile(path.join(IMAGE_DIR, "rezepte", "placeholder", hashedFilename));
|
// Save full size - both hashed and unhashed versions
|
||||||
await sharp(placeholderBuffer)
|
const fullBuffer = await sharp(buffer)
|
||||||
.toFile(path.join(IMAGE_DIR, "rezepte", "placeholder", unhashedFilename))
|
.toFormat('webp')
|
||||||
return new Response(JSON.stringify({
|
.webp({ quality: 90 }) // High quality for full size
|
||||||
msg: "Added image successfully",
|
.toBuffer();
|
||||||
filename: hashedFilename
|
|
||||||
}),{
|
await sharp(fullBuffer).toFile(
|
||||||
status: 200,
|
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;
|
}) satisfies RequestHandler;
|
||||||
|
|||||||
213
src/utils/imageValidation.ts
Normal file
213
src/utils/imageValidation.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Image validation utility with comprehensive security checks
|
||||||
|
*
|
||||||
|
* Implements 5-layer security validation:
|
||||||
|
* 1. File size check (5MB max)
|
||||||
|
* 2. Magic bytes validation (detects actual file type)
|
||||||
|
* 3. MIME type verification
|
||||||
|
* 4. Extension validation
|
||||||
|
* 5. Sharp structure validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fileTypeFromBuffer } from 'file-type';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||||
|
|
||||||
|
// Valid magic bytes for image formats
|
||||||
|
const MAGIC_BYTES = {
|
||||||
|
jpeg: [0xFF, 0xD8, 0xFF],
|
||||||
|
png: [0x89, 0x50, 0x4E, 0x47],
|
||||||
|
webp: [0x52, 0x49, 0x46, 0x46] // RIFF header (WebP)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an uploaded image file with comprehensive security checks
|
||||||
|
* @param file - The File object to validate
|
||||||
|
* @returns ValidationResult with valid flag and optional error message
|
||||||
|
*/
|
||||||
|
export async function validateImageFile(file: File): Promise<ValidationResult> {
|
||||||
|
// Layer 1: Check file size
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File size must be less than ${MAX_FILE_SIZE / 1024 / 1024}MB. Current size: ${(file.size / 1024 / 1024).toFixed(2)}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'File is empty'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Check MIME type (client-provided)
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid file type. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}. Received: ${file.type || 'unknown'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 3: Check file extension
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
if (!extension || !ALLOWED_EXTENSIONS.includes(extension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid file extension. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}. Received: ${extension || 'none'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert File to Buffer for magic bytes validation
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// Layer 4: Magic bytes validation using file-type library
|
||||||
|
try {
|
||||||
|
const fileType = await fileTypeFromBuffer(buffer);
|
||||||
|
|
||||||
|
if (!fileType) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Unable to detect file type from file headers. File may be corrupted or not a valid image.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify detected type matches allowed types
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(fileType.mime)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File headers indicate type "${fileType.mime}" which is not allowed. This file may have been renamed to bypass filters.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify MIME type consistency
|
||||||
|
if (fileType.mime !== file.type) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `File type mismatch: claimed to be "${file.type}" but actual type is "${fileType.mime}". Possible file spoofing attempt.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Failed to validate file headers: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 5: Validate image structure with Sharp
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(buffer).metadata();
|
||||||
|
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Invalid image: unable to read image dimensions'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.width > 10000 || metadata.height > 10000) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Image dimensions too large: ${metadata.width}x${metadata.height}. Maximum: 10000x10000px`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid or corrupted image file: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Buffer containing image data (for base64-decoded images)
|
||||||
|
* @param buffer - The Buffer to validate
|
||||||
|
* @param filename - Original filename for extension validation
|
||||||
|
* @returns ValidationResult with valid flag and optional error message
|
||||||
|
*/
|
||||||
|
export async function validateImageBuffer(buffer: Buffer, filename: string): Promise<ValidationResult> {
|
||||||
|
// Layer 1: Check buffer size
|
||||||
|
if (buffer.length > MAX_FILE_SIZE) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Buffer size must be less than ${MAX_FILE_SIZE / 1024 / 1024}MB. Current size: ${(buffer.length / 1024 / 1024).toFixed(2)}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Buffer is empty'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 2: Check file extension
|
||||||
|
const extension = filename.split('.').pop()?.toLowerCase();
|
||||||
|
if (!extension || !ALLOWED_EXTENSIONS.includes(extension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid file extension. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}. Received: ${extension || 'none'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 3: Magic bytes validation
|
||||||
|
try {
|
||||||
|
const fileType = await fileTypeFromBuffer(buffer);
|
||||||
|
|
||||||
|
if (!fileType) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Unable to detect file type from buffer headers. Buffer may be corrupted.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.includes(fileType.mime)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Buffer headers indicate type "${fileType.mime}" which is not allowed.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Failed to validate buffer headers: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 4: Validate image structure with Sharp
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(buffer).metadata();
|
||||||
|
|
||||||
|
if (!metadata.width || !metadata.height) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Invalid image buffer: unable to read image dimensions'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.width > 10000 || metadata.height > 10000) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Image dimensions too large: ${metadata.width}x${metadata.height}. Maximum: 10000x10000px`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Invalid or corrupted image buffer: ${error.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
371
src/utils/recipeFormHelpers.ts
Normal file
371
src/utils/recipeFormHelpers.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
/**
|
||||||
|
* Recipe form serialization and validation helpers
|
||||||
|
*
|
||||||
|
* Utilities for converting between complex recipe data structures and FormData
|
||||||
|
* for SvelteKit form actions with progressive enhancement support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RecipeFormData {
|
||||||
|
// Basic fields
|
||||||
|
name: string;
|
||||||
|
short_name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
icon: string;
|
||||||
|
tags: string[];
|
||||||
|
portions: string;
|
||||||
|
season: number[];
|
||||||
|
|
||||||
|
// Optional text fields
|
||||||
|
preamble?: string;
|
||||||
|
addendum?: string;
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
// Complex nested structures
|
||||||
|
ingredients: any[];
|
||||||
|
instructions: any[];
|
||||||
|
|
||||||
|
// Additional info
|
||||||
|
add_info: {
|
||||||
|
preparation?: string;
|
||||||
|
fermentation?: {
|
||||||
|
bulk?: string;
|
||||||
|
final?: string;
|
||||||
|
};
|
||||||
|
baking?: {
|
||||||
|
length?: string;
|
||||||
|
temperature?: string;
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
total_time?: string;
|
||||||
|
cooking?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Images
|
||||||
|
images?: Array<{
|
||||||
|
mediapath: string;
|
||||||
|
alt: string;
|
||||||
|
caption: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
isBaseRecipe?: boolean;
|
||||||
|
datecreated?: Date;
|
||||||
|
datemodified?: Date;
|
||||||
|
|
||||||
|
// Translation data (optional)
|
||||||
|
translations?: {
|
||||||
|
en?: any;
|
||||||
|
};
|
||||||
|
translationMetadata?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts recipe data from FormData
|
||||||
|
* Handles both simple fields and complex JSON-encoded nested structures
|
||||||
|
*/
|
||||||
|
export function extractRecipeFromFormData(formData: FormData): RecipeFormData {
|
||||||
|
// Simple fields
|
||||||
|
const name = formData.get('name')?.toString() || '';
|
||||||
|
const short_name = formData.get('short_name')?.toString().trim() || '';
|
||||||
|
const description = formData.get('description')?.toString() || '';
|
||||||
|
const category = formData.get('category')?.toString() || '';
|
||||||
|
const icon = formData.get('icon')?.toString() || '';
|
||||||
|
const portions = formData.get('portions')?.toString() || '';
|
||||||
|
|
||||||
|
// Tags (comma-separated string or JSON array)
|
||||||
|
let tags: string[] = [];
|
||||||
|
const tagsData = formData.get('tags')?.toString();
|
||||||
|
if (tagsData) {
|
||||||
|
try {
|
||||||
|
tags = JSON.parse(tagsData);
|
||||||
|
} catch {
|
||||||
|
// Fallback: split by comma
|
||||||
|
tags = tagsData.split(',').map(t => t.trim()).filter(t => t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Season (JSON array of month numbers)
|
||||||
|
let season: number[] = [];
|
||||||
|
const seasonData = formData.get('season')?.toString();
|
||||||
|
if (seasonData) {
|
||||||
|
try {
|
||||||
|
season = JSON.parse(seasonData);
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid season data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional text fields
|
||||||
|
const preamble = formData.get('preamble')?.toString();
|
||||||
|
const addendum = formData.get('addendum')?.toString();
|
||||||
|
const note = formData.get('note')?.toString();
|
||||||
|
|
||||||
|
// Complex nested structures (JSON-encoded)
|
||||||
|
let ingredients: any[] = [];
|
||||||
|
const ingredientsData = formData.get('ingredients_json')?.toString();
|
||||||
|
if (ingredientsData) {
|
||||||
|
try {
|
||||||
|
ingredients = JSON.parse(ingredientsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse ingredients:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instructions: any[] = [];
|
||||||
|
const instructionsData = formData.get('instructions_json')?.toString();
|
||||||
|
if (instructionsData) {
|
||||||
|
try {
|
||||||
|
instructions = JSON.parse(instructionsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse instructions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional info (JSON-encoded)
|
||||||
|
let add_info: RecipeFormData['add_info'] = {};
|
||||||
|
const addInfoData = formData.get('add_info_json')?.toString();
|
||||||
|
if (addInfoData) {
|
||||||
|
try {
|
||||||
|
add_info = JSON.parse(addInfoData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse add_info:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
let images: Array<{ mediapath: string; alt: string; caption: string }> = [];
|
||||||
|
const imagesData = formData.get('images_json')?.toString();
|
||||||
|
if (imagesData) {
|
||||||
|
try {
|
||||||
|
images = JSON.parse(imagesData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse images:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
const isBaseRecipe = formData.get('isBaseRecipe') === 'true';
|
||||||
|
const datecreated = formData.get('datecreated')
|
||||||
|
? new Date(formData.get('datecreated')!.toString())
|
||||||
|
: new Date();
|
||||||
|
const datemodified = new Date();
|
||||||
|
|
||||||
|
// Translation data (optional)
|
||||||
|
let translations = undefined;
|
||||||
|
const translationData = formData.get('translation_json')?.toString();
|
||||||
|
if (translationData) {
|
||||||
|
try {
|
||||||
|
const translatedRecipe = JSON.parse(translationData);
|
||||||
|
translations = { en: translatedRecipe };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse translation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let translationMetadata = undefined;
|
||||||
|
const translationMetadataData = formData.get('translation_metadata_json')?.toString();
|
||||||
|
if (translationMetadataData) {
|
||||||
|
try {
|
||||||
|
translationMetadata = JSON.parse(translationMetadataData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse translation metadata:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
short_name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
icon,
|
||||||
|
tags,
|
||||||
|
portions,
|
||||||
|
season,
|
||||||
|
preamble,
|
||||||
|
addendum,
|
||||||
|
note,
|
||||||
|
ingredients,
|
||||||
|
instructions,
|
||||||
|
add_info,
|
||||||
|
images,
|
||||||
|
isBaseRecipe,
|
||||||
|
datecreated,
|
||||||
|
datemodified,
|
||||||
|
translations,
|
||||||
|
translationMetadata
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates required recipe fields
|
||||||
|
* Returns array of error messages (empty if valid)
|
||||||
|
*/
|
||||||
|
export function validateRecipeData(data: RecipeFormData): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!data.name || data.name.trim() === '') {
|
||||||
|
errors.push('Recipe name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.short_name || data.short_name.trim() === '') {
|
||||||
|
errors.push('Short name (URL slug) is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate short_name format (URL-safe)
|
||||||
|
if (data.short_name && !/^[a-z0-9_-]+$/i.test(data.short_name)) {
|
||||||
|
errors.push('Short name must contain only letters, numbers, hyphens, and underscores');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.description || data.description.trim() === '') {
|
||||||
|
errors.push('Description is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.category || data.category.trim() === '') {
|
||||||
|
errors.push('Category is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.ingredients || data.ingredients.length === 0) {
|
||||||
|
errors.push('At least one ingredient is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.instructions || data.instructions.length === 0) {
|
||||||
|
errors.push('At least one instruction is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects which fields have changed between two recipe objects
|
||||||
|
* Used for edit forms to enable partial translation updates
|
||||||
|
*/
|
||||||
|
export function detectChangedFields(original: any, current: any): string[] {
|
||||||
|
const changedFields: string[] = [];
|
||||||
|
|
||||||
|
// Simple field comparison
|
||||||
|
const simpleFields = [
|
||||||
|
'name',
|
||||||
|
'short_name',
|
||||||
|
'description',
|
||||||
|
'category',
|
||||||
|
'icon',
|
||||||
|
'portions',
|
||||||
|
'preamble',
|
||||||
|
'addendum',
|
||||||
|
'note'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of simpleFields) {
|
||||||
|
if (original[field] !== current[field]) {
|
||||||
|
changedFields.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array field comparison (deep compare)
|
||||||
|
if (JSON.stringify(original.tags) !== JSON.stringify(current.tags)) {
|
||||||
|
changedFields.push('tags');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(original.season) !== JSON.stringify(current.season)) {
|
||||||
|
changedFields.push('season');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(original.ingredients) !== JSON.stringify(current.ingredients)) {
|
||||||
|
changedFields.push('ingredients');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(original.instructions) !== JSON.stringify(current.instructions)) {
|
||||||
|
changedFields.push('instructions');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nested object comparison
|
||||||
|
if (JSON.stringify(original.add_info) !== JSON.stringify(current.add_info)) {
|
||||||
|
changedFields.push('add_info');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (JSON.stringify(original.images) !== JSON.stringify(current.images)) {
|
||||||
|
changedFields.push('images');
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses season data from form input
|
||||||
|
* Handles both checkbox-based input and JSON arrays
|
||||||
|
*/
|
||||||
|
export function parseSeasonData(formData: FormData): number[] {
|
||||||
|
const season: number[] = [];
|
||||||
|
|
||||||
|
// Try JSON format first
|
||||||
|
const seasonJson = formData.get('season')?.toString();
|
||||||
|
if (seasonJson) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(seasonJson);
|
||||||
|
} catch {
|
||||||
|
// Fall through to checkbox parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse individual checkbox inputs (season_1, season_2, etc.)
|
||||||
|
for (let month = 1; month <= 12; month++) {
|
||||||
|
if (formData.get(`season_${month}`) === 'true') {
|
||||||
|
season.push(month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return season;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes complex recipe data for storage
|
||||||
|
* Ensures all required fields are present and properly typed
|
||||||
|
*/
|
||||||
|
export function serializeRecipeForDatabase(data: RecipeFormData): any {
|
||||||
|
const recipe: any = {
|
||||||
|
name: data.name,
|
||||||
|
short_name: data.short_name,
|
||||||
|
description: data.description,
|
||||||
|
category: data.category,
|
||||||
|
icon: data.icon || '',
|
||||||
|
tags: data.tags || [],
|
||||||
|
portions: data.portions || '',
|
||||||
|
season: data.season || [],
|
||||||
|
ingredients: data.ingredients || [],
|
||||||
|
instructions: data.instructions || [],
|
||||||
|
isBaseRecipe: data.isBaseRecipe || false,
|
||||||
|
datecreated: data.datecreated || new Date(),
|
||||||
|
datemodified: data.datemodified || new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional fields
|
||||||
|
if (data.preamble) recipe.preamble = data.preamble;
|
||||||
|
if (data.addendum) recipe.addendum = data.addendum;
|
||||||
|
if (data.note) recipe.note = data.note;
|
||||||
|
|
||||||
|
// Additional info
|
||||||
|
if (data.add_info && Object.keys(data.add_info).length > 0) {
|
||||||
|
recipe.preparation = data.add_info.preparation;
|
||||||
|
recipe.fermentation = data.add_info.fermentation;
|
||||||
|
recipe.baking = data.add_info.baking;
|
||||||
|
recipe.total_time = data.add_info.total_time;
|
||||||
|
recipe.cooking = data.add_info.cooking;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
if (data.images && data.images.length > 0) {
|
||||||
|
recipe.images = data.images;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translations
|
||||||
|
if (data.translations) {
|
||||||
|
recipe.translations = data.translations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.translationMetadata) {
|
||||||
|
recipe.translationMetadata = data.translationMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipe;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user