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:
2026-01-13 14:21:15 +01:00
parent deac9e3d1f
commit 0a49e20c02
12 changed files with 1777 additions and 866 deletions

View File

@@ -5,48 +5,134 @@ import "$lib/css/shake.css"
import "$lib/css/icon.css"
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( () => {
fetch(image_preview_url, { method: 'HEAD' })
.then(response => {
if(response.redirected){
image_preview_url = ""
}
})
// Check if image redirects to placeholder by attempting to load it
onMount(() => {
if (image_preview_url) {
const img = new Image();
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){
card_data.tags = []
}
//locals
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(){
var file = this.files[0]
// allowed MIME types
var mime_types = [ 'image/webp' ];
// Client-side validation
const allowed_mime_types = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
const max_size = 5 * 1024 * 1024; // 5MB
// validate MIME
if(mime_types.indexOf(file.type) == -1) {
alert('Error : Incorrect file type');
return;
}
image_preview_url = URL.createObjectURL(file);
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = e => {
img.update(() => e.target.result.split(',')[1]);
};
// Validate MIME type
if(!allowed_mime_types.includes(file.type)) {
upload_error = 'Invalid file type. Please upload a JPEG, PNG, or WebP image.';
alert(upload_error);
return;
}
// Validate file size
if(file.size > max_size) {
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(){
image_preview_url = ""
image_preview_url = "";
uploaded_image_filename = "";
upload_error = "";
}
@@ -344,6 +430,11 @@ input::placeholder{
.tag_input{
width: 12ch;
}
.upload-spinner {
color: white;
font-size: 1.2rem;
font-weight: bold;
}
</style>
@@ -362,11 +453,15 @@ input::placeholder{
{/if}
<label class=img_label for=img_picker>
<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>
<label class=img_label for=img_picker style={uploading ? 'opacity: 0.5; cursor: not-allowed;' : ''}>
{#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>
</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>
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
<div>

View File

@@ -737,10 +737,10 @@ h3{
<div class="reference-container">
<div class="reference-header">
<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>
</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>
</button>
</div>
@@ -748,7 +748,7 @@ h3{
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
</div>
<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>
</button>
</div>
@@ -762,24 +762,24 @@ h3{
<div class=move_buttons_container>
<!-- Empty for consistency -->
</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}
</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}
</button>
<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>
</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>
</button>
</div>
{/each}
</div>
{/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}
</button>
@@ -789,7 +789,7 @@ h3{
</div>
<!-- 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}
</button>
{#if list.itemsAfter && list.itemsAfter.length > 0}
@@ -799,17 +799,17 @@ h3{
<div class=move_buttons_container>
<!-- Empty for consistency -->
</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}
</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}
</button>
<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>
</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>
</button>
</div>
@@ -821,15 +821,15 @@ h3{
<!-- svelte-ignore a11y_click_events_have_key_events -->
<h3>
<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>
</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>
</button>
</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 }
{list.name}
{:else}
@@ -837,38 +837,38 @@ h3{
{/if}
</button>
<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>
<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>
</div>
</h3>
<div class=ingredients_grid>
{#each list.list as ingredient, ingredient_index (ingredient_index)}
<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>
</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>
</button>
</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}
</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}
</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>
<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}
</div>
{/if}
{/each}
<!-- 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>
{t[lang].insertBaseRecipe}
</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="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)}>
<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>
</button>
</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="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)}>
<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>
</button>
</div>
@@ -904,7 +904,7 @@ h3{
<h2>{t[lang].renameCategory}</h2>
<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)} >
<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>
</button>
</div>

View File

@@ -788,10 +788,10 @@ h3{
<div class="reference-container">
<div class="reference-header">
<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>
</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>
</button>
</div>
@@ -799,7 +799,7 @@ h3{
📋 {t[lang].baseRecipe}: {list.name || t[lang].unnamed}
</div>
<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>
</button>
</div>
@@ -815,14 +815,14 @@ h3{
<div class="move_buttons_container step_move_buttons">
<!-- Empty for consistency -->
</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}
</button>
<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>
</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>
</button>
</div>
@@ -831,7 +831,7 @@ h3{
{/each}
</ol>
{/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}
</button>
@@ -841,7 +841,7 @@ h3{
</div>
<!-- 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}
</button>
{#if list.stepsAfter && list.stepsAfter.length > 0}
@@ -853,14 +853,14 @@ h3{
<div class="move_buttons_container step_move_buttons">
<!-- Empty for consistency -->
</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}
</button>
<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>
</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>
</button>
</div>
@@ -874,23 +874,23 @@ h3{
<!-- svelte-ignore a11y_click_events_have_key_events -->
<h3>
<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>
</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>
</button>
</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}
{list.name}
{:else}
{t[lang].empty}
{/if}
</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>
<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>
</h3>
@@ -899,21 +899,21 @@ h3{
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li>
<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>
</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>
</button>
</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}
</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>
</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>
</button>
</div></div>
@@ -924,7 +924,7 @@ h3{
{/each}
<!-- 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>
{t[lang].insertBaseRecipe}
</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)} >
<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>
<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>
</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)}>
<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>
<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>
</button>
</div>
@@ -957,7 +957,7 @@ h3{
<h2>{t[lang].renameCategory}</h2>
<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)}>
<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>
</button>
</div>

View File

@@ -37,7 +37,8 @@
// Helper function to initialize images array for English translation
function initializeImagesArray(germanImages: any[]): any[] {
if (!germanImages || germanImages.length === 0) return [];
return germanImages.map(() => ({
return germanImages.map((img) => ({
mediapath: img.mediapath || '',
alt: '',
caption: ''
}));
@@ -64,6 +65,14 @@
let untranslatedBaseRecipes = $state<{ shortName: string, name: string }[]>([]);
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
async function syncBaseRecipeReferences() {
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;">
<h4 style="margin-top: 0; color: var(--nord0);">🖼️ Images - English Alt Texts & Captions</h4>
{#each germanData.images as germanImage, i}
<div style="background-color: white; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; border: 2px solid var(--nord9);">
<div style="display: flex; gap: 1rem; align-items: start;">
<img
src="https://bocken.org/static/rezepte/thumb/{germanImage.mediapath}"
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>
{#if editableEnglish.images && editableEnglish.images[i]}
<div style="background-color: white; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; border: 2px solid var(--nord9);">
<div style="display: flex; gap: 1rem; align-items: start;">
<img
src="https://bocken.org/static/rezepte/thumb/{germanImage.mediapath}"
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="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.75rem;">
<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>
<input
id="german-alt-{i}"
type="text"
value={germanImage.alt || ''}
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;"
/>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.75rem;">
<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>
<input
id="german-alt-{i}"
type="text"
value={germanImage.alt || ''}
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;"
/>
</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>
<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>
<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
id="german-caption-{i}"
type="text"
value={germanImage.caption || ''}
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;"
/>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<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>
<input
id="german-caption-{i}"
type="text"
value={germanImage.caption || ''}
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;"
/>
</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>
<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;">
<GenerateAltTextButton shortName={germanData.short_name} imageIndex={i} />
<div style="margin-top: 0.75rem;">
<GenerateAltTextButton shortName={germanData.short_name} imageIndex={i} />
</div>
</div>
</div>
</div>
</div>
{/if}
{/each}
</div>
{/if}