org.springframework.boot
spring-boot-starter-test
diff --git a/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java b/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java
new file mode 100644
index 0000000..3f695a3
--- /dev/null
+++ b/backend/src/main/java/com/recipeapp/recipe/ImageCompressor.java
@@ -0,0 +1,60 @@
+package com.recipeapp.recipe;
+
+import net.coobird.thumbnailator.Thumbnails;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.Base64;
+
+@Component
+public class ImageCompressor {
+
+ private static final Logger log = LoggerFactory.getLogger(ImageCompressor.class);
+
+ private static final int PREVIEW_WIDTH = 400;
+ private static final double PREVIEW_QUALITY = 0.6;
+ private static final String DATA_URI_PREFIX = "data:image/";
+ private static final String BASE64_MARKER = ";base64,";
+ private static final String OUTPUT_PREFIX = "data:image/jpeg;base64,";
+
+ public String compressToPreview(String dataUri) {
+ if (dataUri == null || dataUri.isBlank()) return null;
+ if (!dataUri.startsWith(DATA_URI_PREFIX)) return null;
+
+ int markerIdx = dataUri.indexOf(BASE64_MARKER);
+ if (markerIdx < 0) return null;
+
+ byte[] imageBytes;
+ try {
+ imageBytes = Base64.getDecoder().decode(dataUri.substring(markerIdx + BASE64_MARKER.length()));
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+
+ try {
+ BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageBytes));
+ if (original == null) {
+ log.warn("ImageIO could not decode image — unsupported format (data URI prefix: {})",
+ dataUri.substring(0, Math.min(dataUri.indexOf(',') + 1, 40)));
+ return null;
+ }
+
+ int targetWidth = Math.min(original.getWidth(), PREVIEW_WIDTH);
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ Thumbnails.of(original)
+ .width(targetWidth)
+ .outputFormat("jpeg")
+ .outputQuality(PREVIEW_QUALITY)
+ .toOutputStream(bos);
+ return OUTPUT_PREFIX + Base64.getEncoder().encodeToString(bos.toByteArray());
+ } catch (Exception e) {
+ log.warn("Failed to generate image preview", e);
+ return null;
+ }
+ }
+}
diff --git a/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql b/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql
new file mode 100644
index 0000000..2b7ac80
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V024__add_hero_image_preview.sql
@@ -0,0 +1 @@
+ALTER TABLE recipe ADD COLUMN hero_image_preview text;
diff --git a/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java b/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java
new file mode 100644
index 0000000..8d2c7c5
--- /dev/null
+++ b/backend/src/test/java/com/recipeapp/recipe/ImageCompressorTest.java
@@ -0,0 +1,98 @@
+package com.recipeapp.recipe;
+
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.Base64;
+
+import static org.assertj.core.api.Assertions.*;
+
+class ImageCompressorTest {
+
+ private final ImageCompressor compressor = new ImageCompressor();
+
+ @Test
+ void compressToPreview_returnsJpegDataUri() throws Exception {
+ String dataUri = makePngDataUri(800, 600);
+ String result = compressor.compressToPreview(dataUri);
+ assertThat(result).startsWith("data:image/jpeg;base64,");
+ }
+
+ @Test
+ void compressToPreview_outputIsDecodableJpeg() throws Exception {
+ String dataUri = makePngDataUri(800, 600);
+ String result = compressor.compressToPreview(dataUri);
+
+ String base64 = result.substring("data:image/jpeg;base64,".length());
+ byte[] bytes = Base64.getDecoder().decode(base64);
+ BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes));
+
+ assertThat(img).isNotNull();
+ assertThat(img.getWidth()).isLessThanOrEqualTo(400);
+ }
+
+ @Test
+ void compressToPreview_preservesAspectRatio() throws Exception {
+ String dataUri = makePngDataUri(800, 400); // 2:1 ratio
+ String result = compressor.compressToPreview(dataUri);
+
+ String base64 = result.substring("data:image/jpeg;base64,".length());
+ BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
+
+ assertThat(img).isNotNull();
+ double ratio = (double) img.getWidth() / img.getHeight();
+ assertThat(ratio).isCloseTo(2.0, within(0.1));
+ }
+
+ @Test
+ void compressToPreview_doesNotUpscaleSmallImages() throws Exception {
+ String dataUri = makePngDataUri(200, 150); // smaller than 400px
+ String result = compressor.compressToPreview(dataUri);
+
+ String base64 = result.substring("data:image/jpeg;base64,".length());
+ BufferedImage img = ImageIO.read(new ByteArrayInputStream(Base64.getDecoder().decode(base64)));
+
+ assertThat(img).isNotNull();
+ assertThat(img.getWidth()).isLessThanOrEqualTo(200);
+ }
+
+ @Test
+ void compressToPreview_returnsNullForNull() {
+ assertThat(compressor.compressToPreview(null)).isNull();
+ }
+
+ @Test
+ void compressToPreview_returnsNullForBlankString() {
+ assertThat(compressor.compressToPreview(" ")).isNull();
+ }
+
+ @Test
+ void compressToPreview_returnsNullForNonDataUri() {
+ assertThat(compressor.compressToPreview("https://example.com/image.jpg")).isNull();
+ }
+
+ @Test
+ void compressToPreview_returnsNullForInvalidBase64() {
+ assertThat(compressor.compressToPreview("data:image/jpeg;base64,!!!not-valid!!!")).isNull();
+ }
+
+ // ── helpers ──
+
+ private String makePngDataUri(int width, int height) throws Exception {
+ BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ Graphics2D g = img.createGraphics();
+ // draw gradient so PNG and JPEG both have non-trivial content
+ for (int x = 0; x < width; x++) {
+ g.setColor(new Color(x * 255 / width, (x * 128 / width + height / 2) % 256, 128));
+ g.drawLine(x, 0, x, height);
+ }
+ g.dispose();
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ ImageIO.write(img, "png", bos);
+ return "data:image/png;base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
+ }
+}
diff --git a/frontend/src/lib/recipes/RecipeCard.svelte b/frontend/src/lib/recipes/RecipeCard.svelte
index d38249b..67318c8 100644
--- a/frontend/src/lib/recipes/RecipeCard.svelte
+++ b/frontend/src/lib/recipes/RecipeCard.svelte
@@ -23,8 +23,8 @@
data-testid="image-area"
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
>
- {#if recipe.heroImageUrl}
-
+ {#if recipe.heroImagePreview}
+
{:else}
{
@@ -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');
});
diff --git a/frontend/src/lib/recipes/types.ts b/frontend/src/lib/recipes/types.ts
index 90f28b5..b01a061 100644
--- a/frontend/src/lib/recipes/types.ts
+++ b/frontend/src/lib/recipes/types.ts
@@ -3,7 +3,7 @@ export type RecipeSummary = {
name: string;
cookTimeMin?: number;
effort?: string;
- heroImageUrl?: string;
+ heroImagePreview?: string;
};
export type Tag = {
diff --git a/frontend/src/routes/(app)/recipes/+page.server.ts b/frontend/src/routes/(app)/recipes/+page.server.ts
index df455b0..3b8bfe8 100644
--- a/frontend/src/routes/(app)/recipes/+page.server.ts
+++ b/frontend/src/routes/(app)/recipes/+page.server.ts
@@ -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 =