diff --git a/backend/cmd/server/router.go b/backend/cmd/server/router.go
index 75fe70d..68df4b6 100644
--- a/backend/cmd/server/router.go
+++ b/backend/cmd/server/router.go
@@ -39,6 +39,7 @@ func New(pool *pgxpool.Pool) *echo.Echo {
g.GET("/:id/images", fruits.ListImages)
g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M"))
g.GET("/:id/images/:imageId", fruits.ServeImage)
+ g.GET("/:id/images/:imageId/thumbnail", fruits.ServeThumbnail)
g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
// Publications (story #04)
@@ -68,7 +69,13 @@ func New(pool *pgxpool.Pool) *echo.Echo {
p.GET("/:id/fruit-images", pubs.ListFruitImages)
p.POST("/:id/fruit-images", pubs.UploadFruitImage, middleware.BodyLimit("5M"))
p.GET("/:id/fruit-images/:imgId", pubs.ServeFruitImage)
+ p.GET("/:id/fruit-images/:imgId/thumbnail", pubs.ServeFruitImageThumbnail)
p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage)
+ // Admin (story #07)
+ admin := handler.NewAdminHandler(fruitRepo, pubRepo)
+ adminGroup := api.Group("/admin")
+ adminGroup.POST("/backfill-thumbnails", admin.BackfillThumbnails)
+
return e
}
diff --git a/backend/internal/domain/fruit.go b/backend/internal/domain/fruit.go
index e43d96b..a1bb631 100644
--- a/backend/internal/domain/fruit.go
+++ b/backend/internal/domain/fruit.go
@@ -11,15 +11,16 @@ var FruitTypeAliases = map[string][]string{
}
type Fruit struct {
- ID int `json:"id"`
- Name string `json:"name"`
- OSDBNumber string `json:"osdb_number"`
- Comment *string `json:"comment"`
- FruitType string `json:"fruit_type"`
- Synonyms []string `json:"synonyms"`
- Images []FruitImage `json:"images"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID int `json:"id"`
+ Name string `json:"name"`
+ OSDBNumber string `json:"osdb_number"`
+ Comment *string `json:"comment"`
+ FruitType string `json:"fruit_type"`
+ Synonyms []string `json:"synonyms"`
+ Images []FruitImage `json:"images"`
+ ThumbnailURL *string `json:"thumbnail_url"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
type FruitImage struct {
diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go
new file mode 100644
index 0000000..d89f9aa
--- /dev/null
+++ b/backend/internal/handler/admin_handler.go
@@ -0,0 +1,44 @@
+package handler
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/labstack/echo/v4"
+)
+
+type FruitBackfiller interface {
+ BackfillThumbnails(ctx context.Context) (int, error)
+}
+
+type PubBackfiller interface {
+ BackfillThumbnails(ctx context.Context) (int, error)
+}
+
+type AdminHandler struct {
+ fruitRepo FruitBackfiller
+ pubRepo PubBackfiller
+}
+
+func NewAdminHandler(fruitRepo FruitBackfiller, pubRepo PubBackfiller) *AdminHandler {
+ return &AdminHandler{fruitRepo: fruitRepo, pubRepo: pubRepo}
+}
+
+func (h *AdminHandler) BackfillThumbnails(c echo.Context) error {
+ ctx := c.Request().Context()
+
+ fruitCount, err := h.fruitRepo.BackfillThumbnails(ctx)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "backfill failed: " + err.Error()})
+ }
+
+ pubCount, err := h.pubRepo.BackfillThumbnails(ctx)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "backfill failed: " + err.Error()})
+ }
+
+ return c.JSON(http.StatusOK, map[string]int{
+ "fruit_images_processed": fruitCount,
+ "pub_fruit_images_processed": pubCount,
+ })
+}
diff --git a/backend/internal/handler/fruit_handler.go b/backend/internal/handler/fruit_handler.go
index fbda29b..21a266a 100644
--- a/backend/internal/handler/fruit_handler.go
+++ b/backend/internal/handler/fruit_handler.go
@@ -29,6 +29,7 @@ type FruitRepository interface {
ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error)
AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error)
GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error)
+ GetThumbnailData(ctx context.Context, fruitID, imageID int) ([]byte, error)
DeleteImage(ctx context.Context, fruitID, imageID int) error
}
@@ -291,3 +292,20 @@ func (h *FruitHandler) DeleteImage(c echo.Context) error {
}
return c.NoContent(http.StatusNoContent)
}
+
+func (h *FruitHandler) ServeThumbnail(c echo.Context) error {
+ fruitID, err := parseID(c, "id")
+ if err != nil {
+ return err
+ }
+ imageID, err := parseID(c, "imageId")
+ if err != nil {
+ return err
+ }
+ data, err := h.repo.GetThumbnailData(c.Request().Context(), fruitID, imageID)
+ if err != nil {
+ return mapRepoError(c, err)
+ }
+ contentType := http.DetectContentType(data)
+ return c.Blob(http.StatusOK, contentType, data)
+}
diff --git a/backend/internal/handler/fruit_handler_test.go b/backend/internal/handler/fruit_handler_test.go
index 8169fe2..2008b10 100644
--- a/backend/internal/handler/fruit_handler_test.go
+++ b/backend/internal/handler/fruit_handler_test.go
@@ -194,6 +194,14 @@ func (r *fakeRepo) GetImageData(_ context.Context, fruitID, imageID int) ([]byte
return r.imageData[imageID], nil
}
+func (r *fakeRepo) GetThumbnailData(_ context.Context, fruitID, imageID int) ([]byte, error) {
+ img, ok := r.images[imageID]
+ if !ok || img.FruitID != fruitID {
+ return nil, handler.ErrNotFound
+ }
+ return r.imageData[imageID], nil
+}
+
func (r *fakeRepo) DeleteImage(_ context.Context, fruitID, imageID int) error {
img, ok := r.images[imageID]
if !ok || img.FruitID != fruitID {
diff --git a/backend/internal/handler/publication_handler.go b/backend/internal/handler/publication_handler.go
index d60bd82..81d5463 100644
--- a/backend/internal/handler/publication_handler.go
+++ b/backend/internal/handler/publication_handler.go
@@ -42,6 +42,7 @@ type PublicationRepository interface {
ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error)
AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error)
GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error)
+ GetFruitImageThumbnailData(ctx context.Context, pubID, imgID int) ([]byte, error)
DeleteFruitImage(ctx context.Context, pubID, imgID int) error
GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error)
@@ -386,6 +387,23 @@ func (h *PublicationHandler) ServeFruitImage(c echo.Context) error {
return c.Blob(http.StatusOK, contentType, data)
}
+func (h *PublicationHandler) ServeFruitImageThumbnail(c echo.Context) error {
+ pubID, err := parseID(c, "id")
+ if err != nil {
+ return err
+ }
+ imgID, err := parseID(c, "imgId")
+ if err != nil {
+ return err
+ }
+ data, err := h.repo.GetFruitImageThumbnailData(c.Request().Context(), pubID, imgID)
+ if err != nil {
+ return mapPubRepoError(c, err)
+ }
+ contentType := http.DetectContentType(data)
+ return c.Blob(http.StatusOK, contentType, data)
+}
+
func (h *PublicationHandler) DeleteFruitImage(c echo.Context) error {
pubID, err := parseID(c, "id")
if err != nil {
diff --git a/backend/internal/handler/publication_handler_test.go b/backend/internal/handler/publication_handler_test.go
index 101aa17..1c9bf5f 100644
--- a/backend/internal/handler/publication_handler_test.go
+++ b/backend/internal/handler/publication_handler_test.go
@@ -265,6 +265,14 @@ func (r *fakePubRepo) GetFruitImageData(_ context.Context, pubID, imgID int) ([]
return r.fruitImgData[imgID], nil
}
+func (r *fakePubRepo) GetFruitImageThumbnailData(_ context.Context, pubID, imgID int) ([]byte, error) {
+ img, ok := r.fruitImages[imgID]
+ if !ok || img.PublicationID != pubID {
+ return nil, handler.ErrNotFound
+ }
+ return r.fruitImgData[imgID], nil
+}
+
func (r *fakePubRepo) DeleteFruitImage(_ context.Context, pubID, imgID int) error {
img, ok := r.fruitImages[imgID]
if !ok || img.PublicationID != pubID {
diff --git a/backend/internal/imaging/crop.go b/backend/internal/imaging/crop.go
new file mode 100644
index 0000000..15b035c
--- /dev/null
+++ b/backend/internal/imaging/crop.go
@@ -0,0 +1,137 @@
+package imaging
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/jpeg"
+ _ "image/png"
+)
+
+const (
+ bgThreshold = 10
+ padding = 5
+ maxDim = 300
+)
+
+// CropToContent auto-crops src to the bounding box of non-background pixels,
+// adds padding, scales to fit within maxDim×maxDim, and re-encodes as JPEG.
+// Returns the original bytes unchanged if decoding fails.
+func CropToContent(src []byte) ([]byte, error) {
+ img, _, err := image.Decode(bytes.NewReader(src))
+ if err != nil {
+ return src, nil
+ }
+
+ bounds := img.Bounds()
+ if bounds.Dx() == 0 || bounds.Dy() == 0 {
+ return src, nil
+ }
+
+ bgColor := img.At(bounds.Min.X, bounds.Min.Y)
+
+ minX, minY, maxX, maxY := findContentBounds(img, bgColor, bounds)
+ if minX > maxX || minY > maxY {
+ return src, nil
+ }
+
+ minX = max(bounds.Min.X, minX-padding)
+ minY = max(bounds.Min.Y, minY-padding)
+ maxX = min(bounds.Max.X-1, maxX+padding)
+ maxY = min(bounds.Max.Y-1, maxY+padding)
+
+ cropRect := image.Rect(minX, minY, maxX+1, maxY+1)
+ cropped := cropImage(img, cropRect)
+ scaled := scaleDown(cropped)
+
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 85}); err != nil {
+ return src, nil
+ }
+ return buf.Bytes(), nil
+}
+
+func findContentBounds(img image.Image, bg color.Color, bounds image.Rectangle) (minX, minY, maxX, maxY int) {
+ minX = bounds.Max.X
+ minY = bounds.Max.Y
+ maxX = bounds.Min.X - 1
+ maxY = bounds.Min.Y - 1
+
+ for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
+ for x := bounds.Min.X; x < bounds.Max.X; x++ {
+ if channelDiff(img.At(x, y), bg) > bgThreshold {
+ if x < minX {
+ minX = x
+ }
+ if y < minY {
+ minY = y
+ }
+ if x > maxX {
+ maxX = x
+ }
+ if y > maxY {
+ maxY = y
+ }
+ }
+ }
+ }
+ return
+}
+
+func channelDiff(c1, c2 color.Color) int {
+ r1, g1, b1, _ := c1.RGBA()
+ r2, g2, b2, _ := c2.RGBA()
+ dr := abs(int(r1>>8) - int(r2>>8))
+ dg := abs(int(g1>>8) - int(g2>>8))
+ db := abs(int(b1>>8) - int(b2>>8))
+ return max(dr, max(dg, db))
+}
+
+func abs(x int) int {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
+
+type subImager interface {
+ SubImage(r image.Rectangle) image.Image
+}
+
+func cropImage(img image.Image, rect image.Rectangle) image.Image {
+ if si, ok := img.(subImager); ok {
+ return si.SubImage(rect)
+ }
+ dst := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
+ draw.Draw(dst, dst.Bounds(), img, rect.Min, draw.Src)
+ return dst
+}
+
+func scaleDown(img image.Image) image.Image {
+ bounds := img.Bounds()
+ w, h := bounds.Dx(), bounds.Dy()
+ if w <= maxDim && h <= maxDim {
+ return img
+ }
+
+ scaleW := float64(maxDim) / float64(w)
+ scaleH := float64(maxDim) / float64(h)
+ scale := scaleW
+ if scaleH < scale {
+ scale = scaleH
+ }
+
+ newW := max(1, int(float64(w)*scale))
+ newH := max(1, int(float64(h)*scale))
+
+ dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
+ for y := 0; y < newH; y++ {
+ for x := 0; x < newW; x++ {
+ srcX := bounds.Min.X + int(float64(x)/scale)
+ srcY := bounds.Min.Y + int(float64(y)/scale)
+ dst.Set(x, y, img.At(srcX, srcY))
+ }
+ }
+ return dst
+}
diff --git a/backend/internal/imaging/crop_test.go b/backend/internal/imaging/crop_test.go
new file mode 100644
index 0000000..33ca011
--- /dev/null
+++ b/backend/internal/imaging/crop_test.go
@@ -0,0 +1,153 @@
+package imaging_test
+
+import (
+ "bytes"
+ "image"
+ "image/color"
+ "image/jpeg"
+ "image/png"
+ "testing"
+
+ "osdb/internal/imaging"
+)
+
+func makeWhitePNG(w, h int) []byte {
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ img.Set(x, y, color.White)
+ }
+ }
+ var buf bytes.Buffer
+ _ = png.Encode(&buf, img)
+ return buf.Bytes()
+}
+
+func makeContentPNG(w, h, contentX, contentY, contentW, contentH int) []byte {
+ img := image.NewRGBA(image.Rect(0, 0, w, h))
+ for y := 0; y < h; y++ {
+ for x := 0; x < w; x++ {
+ img.Set(x, y, color.White)
+ }
+ }
+ for y := contentY; y < contentY+contentH; y++ {
+ for x := contentX; x < contentX+contentW; x++ {
+ img.Set(x, y, color.RGBA{R: 100, G: 50, B: 30, A: 255})
+ }
+ }
+ var buf bytes.Buffer
+ _ = png.Encode(&buf, img)
+ return buf.Bytes()
+}
+
+func imageDims(data []byte) (int, int) {
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ return 0, 0
+ }
+ b := img.Bounds()
+ return b.Dx(), b.Dy()
+}
+
+func TestCropToContent_InvalidData(t *testing.T) {
+ result, err := imaging.CropToContent([]byte("not an image"))
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if !bytes.Equal(result, []byte("not an image")) {
+ t.Error("expected original bytes returned on decode failure")
+ }
+}
+
+func TestCropToContent_AllBackground(t *testing.T) {
+ src := makeWhitePNG(100, 100)
+ result, err := imaging.CropToContent(src)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(result) == 0 {
+ t.Error("expected non-empty result")
+ }
+}
+
+func TestCropToContent_CropsToContent(t *testing.T) {
+ // 200x200 white image with a 20x20 content block at (80,80)
+ src := makeContentPNG(200, 200, 80, 80, 20, 20)
+ result, err := imaging.CropToContent(src)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ w, h := imageDims(result)
+ if w >= 200 || h >= 200 {
+ t.Errorf("expected smaller result, got %dx%d", w, h)
+ }
+}
+
+func TestCropToContent_PreservesContentWithPadding(t *testing.T) {
+ // 200x200 white image with a 30x30 red block at (85,85)
+ src := makeContentPNG(200, 200, 85, 85, 30, 30)
+ result, err := imaging.CropToContent(src)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // Result should be smaller than original but include padding
+ w, h := imageDims(result)
+ if w == 0 || h == 0 {
+ t.Error("expected valid image dimensions")
+ }
+ if w >= 200 || h >= 200 {
+ t.Errorf("expected crop, got %dx%d", w, h)
+ }
+}
+
+func TestCropToContent_ScalesDownLargeImage(t *testing.T) {
+ // 600x600 image with content filling most of it
+ src := makeContentPNG(600, 600, 10, 10, 580, 580)
+ result, err := imaging.CropToContent(src)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ w, h := imageDims(result)
+ if w > 300 || h > 300 {
+ t.Errorf("expected scale-down to ≤300, got %dx%d", w, h)
+ }
+}
+
+func TestCropToContent_ReturnsJPEG(t *testing.T) {
+ src := makeContentPNG(100, 100, 20, 20, 60, 60)
+ result, err := imaging.CropToContent(src)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // JPEG magic bytes: FF D8 FF
+ if len(result) < 3 || result[0] != 0xFF || result[1] != 0xD8 {
+ // Check if we got back original (all-background case returns src)
+ if !bytes.Equal(result, src) {
+ t.Error("expected JPEG output")
+ }
+ }
+}
+
+func TestCropToContent_JPEGInput(t *testing.T) {
+ // Create a JPEG input
+ img := image.NewRGBA(image.Rect(0, 0, 100, 100))
+ for y := 0; y < 100; y++ {
+ for x := 0; x < 100; x++ {
+ if x > 20 && x < 80 && y > 20 && y < 80 {
+ img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255})
+ } else {
+ img.Set(x, y, color.White)
+ }
+ }
+ }
+ var buf bytes.Buffer
+ _ = jpeg.Encode(&buf, img, nil)
+
+ result, err := imaging.CropToContent(buf.Bytes())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(result) == 0 {
+ t.Error("expected non-empty result")
+ }
+}
diff --git a/backend/internal/repository/fruit_repo.go b/backend/internal/repository/fruit_repo.go
index d99385b..4735075 100644
--- a/backend/internal/repository/fruit_repo.go
+++ b/backend/internal/repository/fruit_repo.go
@@ -12,6 +12,7 @@ import (
"osdb/internal/domain"
"osdb/internal/handler"
+ "osdb/internal/imaging"
)
type FruitRepo struct {
@@ -26,6 +27,10 @@ func imageURL(fruitID, imageID int) string {
return fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, imageID)
}
+func thumbnailURL(fruitID, imageID int) string {
+ return fmt.Sprintf("/api/v1/fruits/%d/images/%d/thumbnail", fruitID, imageID)
+}
+
func mapPgError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
@@ -56,7 +61,14 @@ func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, ty
}
rows, err := r.pool.Query(ctx,
- `SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at
+ `SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at,
+ COALESCE((SELECT array_agg(fs2.synonym ORDER BY fs2.id) FROM fruit_synonyms fs2 WHERE fs2.fruit_id = f.id), '{}') AS synonyms,
+ COALESCE(
+ (SELECT '/api/v1/fruits/' || f.id || '/images/' || fi.id || '/thumbnail'
+ FROM fruit_images fi WHERE fi.fruit_id = f.id AND fi.image_type = 'fruit' ORDER BY fi.id LIMIT 1),
+ (SELECT '/api/v1/publications/' || pfi.publication_id || '/fruit-images/' || pfi.id || '/thumbnail'
+ FROM publication_fruit_images pfi WHERE pfi.fruit_id = f.id ORDER BY pfi.id LIMIT 1)
+ ) AS thumbnail_url
FROM fruits f
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
@@ -71,10 +83,13 @@ func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, ty
fruits := []domain.Fruit{}
for rows.Next() {
var f domain.Fruit
- if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt); err != nil {
+ if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt,
+ &f.Synonyms, &f.ThumbnailURL); err != nil {
return nil, 0, err
}
- f.Synonyms = []string{}
+ if f.Synonyms == nil {
+ f.Synonyms = []string{}
+ }
f.Images = []domain.FruitImage{}
fruits = append(fruits, f)
}
@@ -264,7 +279,6 @@ func (r *FruitRepo) ListImages(ctx context.Context, fruitID int) ([]domain.Fruit
}
func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error) {
- // verify fruit exists
var exists bool
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
return domain.FruitImage{}, err
@@ -273,12 +287,14 @@ func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string,
return domain.FruitImage{}, handler.ErrNotFound
}
+ thumbnailData, _ := imaging.CropToContent(data)
+
var img domain.FruitImage
err := r.pool.QueryRow(ctx,
- `INSERT INTO fruit_images (fruit_id, filename, data, image_type, title)
- VALUES ($1, $2, $3, $4, $5)
+ `INSERT INTO fruit_images (fruit_id, filename, data, thumbnail_data, image_type, title)
+ VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, fruit_id, filename, image_type, title, created_at`,
- fruitID, filename, data, imageType, title).
+ fruitID, filename, data, thumbnailData, imageType, title).
Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt)
if err != nil {
return domain.FruitImage{}, err
@@ -300,6 +316,24 @@ func (r *FruitRepo) GetImageData(ctx context.Context, fruitID, imageID int) ([]b
return data, nil
}
+// GetThumbnailData returns thumbnail_data if present, falls back to data.
+func (r *FruitRepo) GetThumbnailData(ctx context.Context, fruitID, imageID int) ([]byte, error) {
+ var thumbnailData, data []byte
+ err := r.pool.QueryRow(ctx,
+ `SELECT thumbnail_data, data FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID).
+ Scan(&thumbnailData, &data)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, handler.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(thumbnailData) > 0 {
+ return thumbnailData, nil
+ }
+ return data, nil
+}
+
func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error {
tag, err := r.pool.Exec(ctx, `DELETE FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID)
if err != nil {
@@ -310,3 +344,40 @@ func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error
}
return nil
}
+
+// BackfillThumbnails generates thumbnail_data for all fruit_images rows missing it.
+// Returns count of rows processed.
+func (r *FruitRepo) BackfillThumbnails(ctx context.Context) (int, error) {
+ rows, err := r.pool.Query(ctx, `SELECT id, fruit_id, data FROM fruit_images WHERE thumbnail_data IS NULL`)
+ if err != nil {
+ return 0, err
+ }
+ defer rows.Close()
+
+ type row struct {
+ id, fruitID int
+ data []byte
+ }
+ var pending []row
+ for rows.Next() {
+ var rw row
+ if err := rows.Scan(&rw.id, &rw.fruitID, &rw.data); err != nil {
+ return 0, err
+ }
+ pending = append(pending, rw)
+ }
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+
+ count := 0
+ for _, rw := range pending {
+ thumb, _ := imaging.CropToContent(rw.data)
+ if _, err := r.pool.Exec(ctx,
+ `UPDATE fruit_images SET thumbnail_data=$1 WHERE id=$2`, thumb, rw.id); err != nil {
+ return count, err
+ }
+ count++
+ }
+ return count, nil
+}
diff --git a/backend/internal/repository/publication_repo.go b/backend/internal/repository/publication_repo.go
index 726db24..380beeb 100644
--- a/backend/internal/repository/publication_repo.go
+++ b/backend/internal/repository/publication_repo.go
@@ -10,6 +10,7 @@ import (
"osdb/internal/domain"
"osdb/internal/handler"
+ "osdb/internal/imaging"
)
type PublicationRepo struct {
@@ -332,12 +333,15 @@ func (r *PublicationRepo) AddFruitImage(ctx context.Context, pubID, fruitID int,
if err := r.requirePublication(ctx, pubID); err != nil {
return domain.PublicationFruitImage{}, err
}
+
+ thumbnailData, _ := imaging.CropToContent(data)
+
var img domain.PublicationFruitImage
err := r.pool.QueryRow(ctx,
- `INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, image_type)
- VALUES ($1, $2, $3, $4, 'fruit')
+ `INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, thumbnail_data, image_type)
+ VALUES ($1, $2, $3, $4, $5, 'fruit')
RETURNING id, publication_id, fruit_id, filename, image_type, created_at`,
- pubID, fruitID, filename, data).
+ pubID, fruitID, filename, data, thumbnailData).
Scan(&img.ID, &img.PublicationID, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt)
if err != nil {
return domain.PublicationFruitImage{}, err
@@ -357,6 +361,24 @@ func (r *PublicationRepo) GetFruitImageData(ctx context.Context, pubID, imgID in
return data, err
}
+// GetFruitImageThumbnailData returns thumbnail_data if present, falls back to data.
+func (r *PublicationRepo) GetFruitImageThumbnailData(ctx context.Context, pubID, imgID int) ([]byte, error) {
+ var thumbnailData, data []byte
+ err := r.pool.QueryRow(ctx,
+ `SELECT thumbnail_data, data FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`,
+ imgID, pubID).Scan(&thumbnailData, &data)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, handler.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ if len(thumbnailData) > 0 {
+ return thumbnailData, nil
+ }
+ return data, nil
+}
+
func (r *PublicationRepo) DeleteFruitImage(ctx context.Context, pubID, imgID int) error {
tag, err := r.pool.Exec(ctx,
`DELETE FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`, imgID, pubID)
@@ -426,3 +448,41 @@ func (r *PublicationRepo) requirePublication(ctx context.Context, pubID int) err
}
return nil
}
+
+// BackfillThumbnails generates thumbnail_data for all publication_fruit_images rows missing it.
+// Returns count of rows processed.
+func (r *PublicationRepo) BackfillThumbnails(ctx context.Context) (int, error) {
+ rows, err := r.pool.Query(ctx,
+ `SELECT id, data FROM publication_fruit_images WHERE thumbnail_data IS NULL`)
+ if err != nil {
+ return 0, err
+ }
+ defer rows.Close()
+
+ type row struct {
+ id int
+ data []byte
+ }
+ var pending []row
+ for rows.Next() {
+ var rw row
+ if err := rows.Scan(&rw.id, &rw.data); err != nil {
+ return 0, err
+ }
+ pending = append(pending, rw)
+ }
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+
+ count := 0
+ for _, rw := range pending {
+ thumb, _ := imaging.CropToContent(rw.data)
+ if _, err := r.pool.Exec(ctx,
+ `UPDATE publication_fruit_images SET thumbnail_data=$1 WHERE id=$2`, thumb, rw.id); err != nil {
+ return count, err
+ }
+ count++
+ }
+ return count, nil
+}
diff --git a/backend/migrations/000004_add_thumbnails.down.sql b/backend/migrations/000004_add_thumbnails.down.sql
new file mode 100644
index 0000000..d4bb945
--- /dev/null
+++ b/backend/migrations/000004_add_thumbnails.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE fruit_images DROP COLUMN thumbnail_data;
+ALTER TABLE publication_fruit_images DROP COLUMN thumbnail_data;
diff --git a/backend/migrations/000004_add_thumbnails.up.sql b/backend/migrations/000004_add_thumbnails.up.sql
new file mode 100644
index 0000000..17d1b5d
--- /dev/null
+++ b/backend/migrations/000004_add_thumbnails.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE fruit_images ADD COLUMN thumbnail_data BYTEA;
+ALTER TABLE publication_fruit_images ADD COLUMN thumbnail_data BYTEA;
diff --git a/frontend/src/api/fruits.ts b/frontend/src/api/fruits.ts
index 0a4a6e6..2a50629 100644
--- a/frontend/src/api/fruits.ts
+++ b/frontend/src/api/fruits.ts
@@ -38,6 +38,7 @@ export interface Fruit {
fruit_type: string
synonyms: string[]
images: FruitImage[]
+ thumbnail_url: string | null
created_at: string
updated_at: string
}
diff --git a/frontend/src/components/FruitCard.vue b/frontend/src/components/FruitCard.vue
new file mode 100644
index 0000000..bdef7b3
--- /dev/null
+++ b/frontend/src/components/FruitCard.vue
@@ -0,0 +1,31 @@
+
+
+
+ {{ fruit.name }} {{ fruit.osdb_number }} {{ fruit.fruit_type }}
+ {{ fruit.synonyms.join(', ') }}
+
+ 🌿
+
| Name | -OSDB-Kürzel | -Typ | -
|---|---|---|
| - Keine Früchte vorhanden. - | -||
|
- |
- {{ fruit.osdb_number }} | -{{ fruit.fruit_type }} | -