feat(recipe): compress hero image to 400px preview on save

Adds Thumbnailator-based ImageCompressor that resizes uploaded images
to a 400px-wide JPEG preview stored in hero_image_preview. The recipe
list uses the preview instead of the full image URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 09:14:35 +02:00
parent 822b34cd14
commit f11cca534f
8 changed files with 179 additions and 10 deletions

View File

@@ -23,8 +23,8 @@
data-testid="image-area"
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
>
{#if recipe.heroImageUrl}
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
{#if recipe.heroImagePreview}
<img src={recipe.heroImagePreview} alt={recipe.name} class="w-full h-full object-cover" />
{:else}
<div
data-testid="image-placeholder"

View File

@@ -8,7 +8,7 @@ const mockRecipe = {
name: 'Spaghetti Bolognese',
cookTimeMin: 30,
effort: 'Easy',
heroImageUrl: undefined
heroImagePreview: undefined
};
describe('RecipeCard', () => {
@@ -27,18 +27,18 @@ describe('RecipeCard', () => {
expect(screen.getByText(/easy/i)).toBeInTheDocument();
});
it('shows placeholder when no heroImageUrl', () => {
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } });
it('shows placeholder when no heroImagePreview', () => {
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImagePreview: undefined } } });
expect(screen.queryByRole('img')).not.toBeInTheDocument();
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
});
it('shows image when heroImageUrl is provided', () => {
it('shows image when heroImagePreview is provided', () => {
render(RecipeCard, {
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } }
props: { recipe: { ...mockRecipe, heroImagePreview: 'data:image/jpeg;base64,abc' } }
});
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/uploads/test.jpg');
expect(img).toHaveAttribute('src', 'data:image/jpeg;base64,abc');
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
});

View File

@@ -3,7 +3,7 @@ export type RecipeSummary = {
name: string;
cookTimeMin?: number;
effort?: string;
heroImageUrl?: string;
heroImagePreview?: string;
};
export type Tag = {

View File

@@ -20,7 +20,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
name: r.name!,
cookTimeMin: r.cookTimeMin,
effort: r.effort,
heroImageUrl: r.heroImageUrl
heroImagePreview: r.heroImagePreview
}));
const activePlan =