feat(recipes): image upload, fix save 500, HelloFresh seed data #53
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE recipe ADD COLUMN hero_image_preview text;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export type RecipeSummary = {
|
||||
name: string;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
heroImagePreview?: string;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user