Files
homepage/src/routes/[recipeLang=recipeLang]/edit/[name]/+page.svelte
Alexander Bocken 4ffc0940ef
All checks were successful
CI / update (push) Successful in 1m10s
feat: add AI-powered alt text generation for recipe images
- Implement local Ollama integration for bilingual (DE/EN) alt text generation
- Add image management UI to German edit page and English translation section
- Update Card and recipe detail pages to display alt text from images array
- Include GenerateAltTextButton component for manual alt text generation
- Add bulk processing admin page for batch alt text generation
- Optimize images to 1024x1024 before AI processing for 75% faster generation
- Store alt text in recipe.images[].alt and translations.en.images[].alt
2026-01-05 17:28:19 +01:00

617 lines
16 KiB
Svelte

<script lang="ts">
import Check from '$lib/assets/icons/Check.svelte';
import Cross from '$lib/assets/icons/Cross.svelte';
import SeasonSelect from '$lib/components/SeasonSelect.svelte';
import TranslationApproval from '$lib/components/TranslationApproval.svelte';
import GenerateAltTextButton from '$lib/components/GenerateAltTextButton.svelte';
import '$lib/css/action_button.css'
import '$lib/css/nordtheme.css'
import { redirect } from '@sveltejs/kit';
import EditRecipeNote from '$lib/components/EditRecipeNote.svelte';
export let data: PageData;
let preamble = data.recipe.preamble
let addendum = data.recipe.addendum
let image_preview_url="https://bocken.org/static/rezepte/thumb/" + (data.recipe.images?.[0]?.mediapath || `${data.recipe.short_name}.webp`);
let note = data.recipe.note
// Translation workflow state
let showTranslationWorkflow = false;
let translationData: any = data.recipe.translations?.en || null;
let changedFields: string[] = [];
// Store original recipe data for change detection
const originalRecipe = JSON.parse(JSON.stringify(data.recipe));
import { season } from '$lib/js/season_store';
import { portions } from '$lib/js/portions_store';
portions.update(() => data.recipe.portions)
let portions_local
portions.subscribe((p) => {
portions_local = p
});
season.update(() => data.recipe.season)
let season_local
season.subscribe((s) => {
season_local = s
});
import { img } from '$lib/js/img_store';
let img_local
img.update(() => "")
img.subscribe((i) => {
img_local = i});
let old_short_name = data.recipe.short_name
export let card_data ={
icon: data.recipe.icon,
category: data.recipe.category,
name: data.recipe.name,
description: data.recipe.description,
tags: data.recipe.tags,
}
export let add_info ={
preparation: data.recipe.preparation,
fermentation: {
bulk: data.recipe.fermentation.bulk,
final: data.recipe.fermentation.final,
},
baking: {
length: data.recipe.baking.length,
temperature: data.recipe.baking.temperature,
mode: data.recipe.baking.mode,
},
total_time: data.recipe.total_time,
cooking: data.recipe.cooking,
}
let images = data.recipe.images
let short_name = data.recipe.short_name
let datecreated = data.recipe.datecreated
let datemodified = new Date()
let isBaseRecipe = data.recipe.isBaseRecipe || false
import type { PageData } from './$types';
import CardAdd from '$lib/components/CardAdd.svelte';
import CreateIngredientList from '$lib/components/CreateIngredientList.svelte';
export let ingredients = data.recipe.ingredients
import CreateStepList from '$lib/components/CreateStepList.svelte';
export let instructions = data.recipe.instructions
function get_season(){
let season = []
const el = document.getElementById("labels");
for(var i = 0; i < el.children.length; i++){
if(el.children[i].children[0].children[0].checked){
season.push(i+1)
}
}
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
function getCurrentRecipeData() {
return {
...card_data,
...add_info,
images,
season: season_local,
short_name: short_name.trim(),
datecreated,
portions: portions_local,
datemodified,
instructions,
ingredients,
addendum,
preamble,
note,
isBaseRecipe,
};
}
// Detect which fields have changed from the original
function detectChangedFields() {
const current = getCurrentRecipeData();
const changed: string[] = [];
const fieldsToCheck = [
'name', 'description', 'preamble', 'addendum',
'note', 'category', 'tags', 'portions', 'preparation',
'cooking', 'total_time', 'baking', 'fermentation',
'ingredients', 'instructions'
];
for (const field of fieldsToCheck) {
const oldValue = JSON.stringify(originalRecipe[field] || '');
const newValue = JSON.stringify(current[field] || '');
if (oldValue !== newValue) {
changed.push(field);
}
}
return changed;
}
// Show translation workflow before submission
function prepareSubmit() {
// Only detect changed fields if there's an existing translation
// For first-time translations, changedFields should be empty
changedFields = translationData ? detectChangedFields() : [];
showTranslationWorkflow = true;
// Scroll to translation section
setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
// Force full retranslation of entire recipe
function forceFullRetranslation() {
// Set changedFields to empty array to trigger full translation
changedFields = [];
showTranslationWorkflow = true;
// Scroll to translation section
setTimeout(() => {
document.getElementById('translation-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
// Handle translation approval
function handleTranslationApproved(event: CustomEvent) {
translationData = event.detail.translatedRecipe;
doEdit();
}
// Handle translation skipped
function handleTranslationSkipped() {
// Mark translation as needing update if fields changed
if (changedFields.length > 0 && translationData) {
translationData.translationStatus = 'needs_update';
translationData.changedFields = changedFields;
}
doEdit();
}
// Handle translation cancelled
function handleTranslationCancelled() {
showTranslationWorkflow = false;
}
async function doDelete(){
// Check for references if this is a base recipe
const checkRes = await fetch(`/api/rezepte/check-references/${data.recipe._id}`);
const checkData = await checkRes.json();
let response;
if (checkData.isReferenced) {
const refList = checkData.references
.map(r => ` • ${r.name}`)
.join('\n');
response = confirm(
`Dieses Rezept wird von folgenden Rezepten referenziert:\n\n${refList}\n\n` +
`Die Referenzen werden in regulären Inhalt umgewandelt.\n` +
`Möchtest du fortfahren?`
);
} else {
response = confirm("Bist du dir sicher, dass du das Rezept löschen willst?");
}
if(!response){
return
}
const res_img = await fetch('/api/rezepte/img/delete', {
method: 'POST',
body: JSON.stringify({
name: old_short_name,
}),
headers : {
'content-type': 'application/json',
credentials: 'include',
}
})
if(!res_img.ok){
const item = await res_img.json();
//alert(item.message)
return
}
const res = await fetch('/api/rezepte/delete', {
method: 'POST',
body: JSON.stringify({
old_short_name,
}),
headers: {
'content-type': 'application/json',
credentials: 'include',
}
})
if(res.ok){
const url = location.href.split('/')
url.splice(url.length -2, 2);
location.assign(url.join('/'))
}
else{
const item = await res.json();
// alert(item.message)
}
}
async function doEdit() {
// two cases:
//new image uploaded (not implemented yet)
// new short_name -> move images as well
// if new image
console.log("img_local", img_local)
if(img_local != ""){
async function delete_img(){
const res = await fetch('/api/rezepte/img/delete', {
method: 'POST',
body: JSON.stringify({
name: old_short_name,
}),
headers : {
'content-type': 'application/json',
credentials: 'include',
}
})
if(!res.ok){
const item = await res.json();
// alert(item.message)
}
}
async function upload_img(){
const data = {
image: img_local,
name: short_name.trim(),
}
const res = await fetch(`/api/rezepte/img/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
credentials: 'include',
},
body: JSON.stringify(data)
});
if(!res.ok){
const item = await res.json();
// alert(item.message)
}
}
delete_img()
upload_img()
}
// case new short_name:
else if(short_name != old_short_name){
console.log("MOVING")
const res_img = await fetch('/api/rezepte/img/mv', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
credentials: 'include',
},
body: JSON.stringify({
old_name: old_short_name,
new_name: short_name.trim(),
})
})
if(!res_img.ok){
const item = await res_img.json();
//alert(item.message)
return
}
}
const recipeData = getCurrentRecipeData();
// Add translations if available
if (translationData) {
recipeData.translations = {
en: translationData
};
// Update translation metadata
if (changedFields.length > 0) {
recipeData.translationMetadata = {
lastModifiedGerman: new Date(),
fieldsModifiedSinceTranslation: translationData.translationStatus === 'needs_update' ? changedFields : [],
};
}
}
const res = await fetch('/api/rezepte/edit', {
method: 'POST',
body: JSON.stringify({
recipe: recipeData,
old_short_name,
old_recipe: originalRecipe, // For change detection in API
}),
headers: {
'content-type': 'application/json',
credentials: 'include',
}
})
if(res.ok){
const url = location.href.split('/');
url.splice(url.length -2, 2);
url.push(short_name.trim());
location.assign(url.join('/'))
}
else{
const item = await res.json()
//alert(item.message)
}
}
</script>
<style>
input{
display: block;
border: unset;
margin: 1rem auto;
padding: 0.5em 1em;
border-radius: 1000px;
background-color: var(--nord4);
font-size: 1.1rem;
transition: 100ms;
}
input:hover,
input:focus-visible
{
scale: 1.05 1.05;
}
.list_wrapper{
margin-inline: auto;
display: flex;
flex-direction: row;
max-width: 1000px;
gap: 2rem;
justify-content: center;
}
@media screen and (max-width: 700px){
.list_wrapper{
flex-direction: column;
}
}
h1{
text-align: center;
margin-bottom: 2rem;
}
.title_container{
max-width: 1000px;
display: flex;
flex-direction: column;
margin-inline: auto;
}
.title{
position: relative;
width: min(800px, 80vw);
margin-block: 2rem;
margin-inline: auto;
background-color: var(--nord6);
padding: 1rem 2rem;
}
@media (prefers-color-scheme: dark){
.title{
background-color: var(--nord6-dark);
}
}
.title p{
border: 2px solid var(--nord1);
border-radius: 10000px;
padding: 0.5em 1em;
font-size: 1.1rem;
transition: 200ms;
}
.title p:hover,
.title p:focus-within{
scale: 1.02 1.02;
}
.addendum{
font-size: 1.1rem;
max-width: 90%;
margin-inline: auto;
border: 2px solid var(--nord1);
border-radius: 45px;
padding: 1em 1em;
transition: 100ms;
}
.addendum:hover,
.addendum:focus-within
{
scale: 1.02 1.02;
}
.addendum_wrapper{
max-width: 1000px;
margin-inline: auto;
}
h3{
text-align: center;
}
button.action_button{
animation: unset !important;
font-size: 1.3rem;
color: white;
}
.submit_buttons{
display: flex;
margin-inline: auto;
max-width: 1000px;
margin-block: 1rem;
justify-content: center;
align-items: center;
gap: 2rem;
}
.submit_buttons p{
padding: 0;
padding-right: 0.5em;
margin: 0;
}
@media (prefers-color-scheme: dark){
:global(body){
background-color: var(--background-dark);
}
:global(.image-management-section) {
background-color: var(--nord1) !important;
}
:global(.image-item) {
background-color: var(--nord0) !important;
border-color: var(--nord2) !important;
}
:global(.image-item input) {
background-color: var(--nord2) !important;
color: white !important;
border-color: var(--nord3) !important;
}
}
</style>
<h1>Rezept editieren</h1>
<CardAdd {card_data} {image_preview_url} ></CardAdd>
{#if images && images.length > 0}
<div class="image-management-section" style="background-color: var(--nord6); padding: 1.5rem; margin: 2rem auto; max-width: 800px; border-radius: 10px;">
<h3 style="margin-top: 0;">🖼️ Bilder & Alt-Texte</h3>
{#each images as image, i}
<div class="image-item" style="background-color: white; padding: 1rem; margin-bottom: 1rem; border-radius: 5px; border: 1px solid var(--nord4);">
<div style="display: flex; gap: 1rem; align-items: start;">
<img
src="https://bocken.org/static/rezepte/thumb/{image.mediapath}"
alt={image.alt || 'Recipe image'}
style="width: 120px; height: 120px; object-fit: cover; border-radius: 5px;"
/>
<div style="flex: 1;">
<p style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: var(--nord3);"><strong>Bild {i + 1}:</strong> {image.mediapath}</p>
<div style="margin-bottom: 0.75rem;">
<label style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.9rem;">Alt-Text (DE):</label>
<input
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;">
<label style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.9rem;">Caption (DE):</label>
<input
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>
{/if}
<div class=title_container>
<div class=title>
<h4>Eine etwas längere Beschreibung:</h4>
<p bind:innerText={preamble} contenteditable></p>
<div class=tags>
<h4>Saison:</h4>
<SeasonSelect></SeasonSelect>
<EditRecipeNote><p contenteditable bind:innerText={note}></p></EditRecipeNote>
</div>
</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 on:click={doDelete}><p>Löschen</p><Cross fill=white width=2rem height=2rem></Cross></button>
<button class=action_button on:click={prepareSubmit}><p>Weiter zur Übersetzung</p><Check fill=white width=2rem height=2rem></Check></button>
</div>
{/if}
{#if showTranslationWorkflow}
<div id="translation-section">
<TranslationApproval
germanData={getCurrentRecipeData()}
englishData={translationData}
oldRecipeData={originalRecipe}
{changedFields}
isEditMode={true}
on:approved={handleTranslationApproved}
on:skipped={handleTranslationSkipped}
on:cancelled={handleTranslationCancelled}
on:forceFullRetranslation={forceFullRetranslation}
/>
</div>
{/if}