feat: add AI-powered alt text generation for recipe images
All checks were successful
CI / update (push) Successful in 1m10s
All checks were successful
CI / update (push) Successful in 1m10s
- 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
This commit is contained in:
@@ -32,6 +32,11 @@ const img_name = $derived(
|
||||
recipe.images?.[0]?.mediapath ||
|
||||
`${recipe.germanShortName || recipe.short_name}.webp`
|
||||
);
|
||||
|
||||
// Get alt text from images array
|
||||
const img_alt = $derived(
|
||||
recipe.images?.[0]?.alt || recipe.name
|
||||
);
|
||||
</script>
|
||||
<style>
|
||||
.card_anchor{
|
||||
@@ -288,9 +293,9 @@ const img_name = $derived(
|
||||
<div class=div_div_image >
|
||||
<div class=div_image style="background-image:url(https://bocken.org/static/rezepte/placeholder/{img_name})">
|
||||
<noscript>
|
||||
<img class="image backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{recipe.alt}"/>
|
||||
<img class="image backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{img_alt}"/>
|
||||
</noscript>
|
||||
<img class="image backdrop_blur" class:blur={!isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{recipe.alt}" on:load={() => isloaded=true}/>
|
||||
<img class="image backdrop_blur" class:blur={!isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{img_alt}" on:load={() => isloaded=true}/>
|
||||
</div>
|
||||
</div>
|
||||
{#if showFavoriteIndicator && isFavorite}
|
||||
|
||||
92
src/lib/components/GenerateAltTextButton.svelte
Normal file
92
src/lib/components/GenerateAltTextButton.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
let { shortName, imageIndex }: { shortName: string; imageIndex: number } = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state('');
|
||||
|
||||
async function generateAltText() {
|
||||
loading = true;
|
||||
error = '';
|
||||
success = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/generate-alt-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
shortName,
|
||||
imageIndex,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to generate alt text');
|
||||
}
|
||||
|
||||
success = `Generated: DE: "${data.altText.de}" | EN: "${data.altText.en}"`;
|
||||
|
||||
// Reload page to show updated alt text
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'An error occurred';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--nord8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--nord7);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background-color: var(--nord3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: var(--nord14);
|
||||
color: var(--nord0);
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: var(--nord11);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<button onclick={generateAltText} disabled={loading}>
|
||||
{loading ? '🤖 Generating...' : '✨ Generate Alt Text (AI)'}
|
||||
</button>
|
||||
|
||||
{#if success}
|
||||
<div class="message success">{success}</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let { src, placeholder_src } = $props();
|
||||
let { src, placeholder_src, alt = "" } = $props();
|
||||
|
||||
let isloaded = $state(false);
|
||||
let isredirected = $state(false);
|
||||
@@ -179,12 +179,12 @@ dialog button{
|
||||
<div class:zoom-in={isloaded && !isredirected} onclick={show_dialog_img}>
|
||||
<div class=placeholder style="background-image:url({placeholder_src})" >
|
||||
<div class=placeholder_blur>
|
||||
<img class="image" class:unblur={isloaded} {src} onload={() => {isloaded=true}} alt=""/>
|
||||
<img class="image" class:unblur={isloaded} {src} onload={() => {isloaded=true}} {alt}/>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<div class=placeholder style="background-image:url({placeholder_src})" >
|
||||
<img class="image unblur" {src} onload={() => {isloaded=true}} alt=""/>
|
||||
<img class="image unblur" {src} onload={() => {isloaded=true}} {alt}/>
|
||||
</div>
|
||||
</noscript>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@ dialog button{
|
||||
|
||||
<dialog id=img_carousel>
|
||||
<div>
|
||||
<img class:unblur={isloaded} {src} alt="">
|
||||
<img class:unblur={isloaded} {src} {alt}>
|
||||
<button class=action_button onkeydown={(event) => do_on_key(event, 'Enter', false, close_dialog_img)} onclick={close_dialog_img}>
|
||||
<Cross fill=white width=2rem height=2rem></Cross>
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import TranslationFieldComparison from './TranslationFieldComparison.svelte';
|
||||
import CreateIngredientList from './CreateIngredientList.svelte';
|
||||
import CreateStepList from './CreateStepList.svelte';
|
||||
import GenerateAltTextButton from './GenerateAltTextButton.svelte';
|
||||
|
||||
export let germanData: any;
|
||||
export let englishData: TranslatedRecipeType | null = null;
|
||||
@@ -17,8 +18,21 @@
|
||||
let errorMessage: string = '';
|
||||
let validationErrors: string[] = [];
|
||||
|
||||
// Helper function to initialize images array for English translation
|
||||
function initializeImagesArray(germanImages: any[]): any[] {
|
||||
if (!germanImages || germanImages.length === 0) return [];
|
||||
return germanImages.map(() => ({
|
||||
alt: '',
|
||||
caption: ''
|
||||
}));
|
||||
}
|
||||
|
||||
// Editable English data (clone of englishData or initialized from germanData)
|
||||
let editableEnglish: any = englishData ? { ...englishData } : null;
|
||||
let editableEnglish: any = englishData ? {
|
||||
...englishData,
|
||||
// Ensure images array exists and matches German images length
|
||||
images: englishData.images || initializeImagesArray(germanData.images || [])
|
||||
} : null;
|
||||
|
||||
// Store old recipe data for granular change detection
|
||||
export let oldRecipeData: any = null;
|
||||
@@ -67,7 +81,8 @@
|
||||
...germanData,
|
||||
translationStatus: 'pending',
|
||||
ingredients: JSON.parse(JSON.stringify(germanData.ingredients || [])),
|
||||
instructions: JSON.parse(JSON.stringify(germanData.instructions || []))
|
||||
instructions: JSON.parse(JSON.stringify(germanData.instructions || [])),
|
||||
images: editableEnglish?.images || initializeImagesArray(germanData.images || [])
|
||||
};
|
||||
}
|
||||
checkingBaseRecipes = false;
|
||||
@@ -128,7 +143,8 @@
|
||||
return translation ? { ...inst, name: translation.enName } : inst;
|
||||
}
|
||||
return inst;
|
||||
})
|
||||
}),
|
||||
images: initializeImagesArray(germanData.images || [])
|
||||
};
|
||||
} else {
|
||||
// Existing English translation - merge German structure with English translations
|
||||
@@ -182,7 +198,12 @@
|
||||
// If no English translation exists, use German structure (will be translated later)
|
||||
return germanInst;
|
||||
}
|
||||
})
|
||||
}),
|
||||
// Sync images array - keep existing English alt/caption or initialize empty
|
||||
images: germanData.images?.map((germanImg: any, index: number) => {
|
||||
const existingEnImage = editableEnglish.images?.[index];
|
||||
return existingEnImage || { alt: '', caption: '' };
|
||||
}) || []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -758,6 +779,73 @@ button:disabled {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Images Section -->
|
||||
{#if germanData.images && germanData.images.length > 0}
|
||||
<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>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0.75rem;">
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇩🇪 German Alt-Text:</label>
|
||||
<input
|
||||
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 style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇬🇧 English Alt-Text:</label>
|
||||
<input
|
||||
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 style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇩🇪 German Caption:</label>
|
||||
<input
|
||||
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 style="display: block; margin-bottom: 0.25rem; font-weight: bold; font-size: 0.85rem; color: var(--nord0);">🇬🇧 English Caption:</label>
|
||||
<input
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ingredients and Instructions in two-column layout -->
|
||||
{#if editableEnglish?.ingredients || editableEnglish?.instructions}
|
||||
<div class="list-wrapper">
|
||||
|
||||
Reference in New Issue
Block a user