feat(recipes): image upload, fix save 500, HelloFresh seed data #53

Merged
marcel merged 50 commits from feat/issue-46-wire-suggestions-recipe-picker into master 2026-04-10 10:18:10 +02:00
8 changed files with 179 additions and 10 deletions
Showing only changes of commit f11cca534f - Show all commits

View File

@@ -55,6 +55,16 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.21</version>
</dependency>
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-webp</artifactId>
<version>3.13.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE recipe ADD COLUMN hero_image_preview text;

View File

@@ -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());
}
}

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 =