From 649c9687ac582ccc1b47e605ee714b14975e2d35 Mon Sep 17 00:00:00 2001 From: juliaweber Date: Fri, 19 Jun 2026 09:02:14 +0200 Subject: [PATCH] feat: fruit overview with thumbnails and card layout (story #07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-crop thumbnails on image upload (Go stdlib, no external deps): find non-background bounding box, pad 5px, scale to ≤300px, encode JPEG - Add thumbnail_data BYTEA column to fruit_images and publication_fruit_images - New GET /api/v1/fruits/:id/images/:imageId/thumbnail endpoint (fallback to full data) - New GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail endpoint - New POST /api/v1/admin/backfill-thumbnails to retroactively generate thumbnails - ListFruits response now includes synonyms and thumbnail_url per fruit - Replace FruitList table with responsive FruitCard grid (thumbnail + name + OSDB ID + type + synonyms) Co-Authored-By: Claude Sonnet 4.6 --- backend/cmd/server/router.go | 7 + backend/internal/domain/fruit.go | 19 +-- backend/internal/handler/admin_handler.go | 44 +++++ backend/internal/handler/fruit_handler.go | 18 +++ .../internal/handler/fruit_handler_test.go | 8 + .../internal/handler/publication_handler.go | 18 +++ .../handler/publication_handler_test.go | 8 + backend/internal/imaging/crop.go | 137 ++++++++++++++++ backend/internal/imaging/crop_test.go | 153 ++++++++++++++++++ backend/internal/repository/fruit_repo.go | 85 +++++++++- .../internal/repository/publication_repo.go | 66 +++++++- .../migrations/000004_add_thumbnails.down.sql | 2 + .../migrations/000004_add_thumbnails.up.sql | 2 + frontend/src/api/fruits.ts | 1 + frontend/src/components/FruitCard.vue | 31 ++++ frontend/src/stores/fruitStore.test.ts | 1 + frontend/src/views/FruitList.vue | 39 ++--- 17 files changed, 591 insertions(+), 48 deletions(-) create mode 100644 backend/internal/handler/admin_handler.go create mode 100644 backend/internal/imaging/crop.go create mode 100644 backend/internal/imaging/crop_test.go create mode 100644 backend/migrations/000004_add_thumbnails.down.sql create mode 100644 backend/migrations/000004_add_thumbnails.up.sql create mode 100644 frontend/src/components/FruitCard.vue 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 @@ + + + diff --git a/frontend/src/stores/fruitStore.test.ts b/frontend/src/stores/fruitStore.test.ts index 40cf00f..c18333e 100644 --- a/frontend/src/stores/fruitStore.test.ts +++ b/frontend/src/stores/fruitStore.test.ts @@ -11,6 +11,7 @@ const makeFruit = (id: number, name = 'Boskop'): Fruit => ({ fruit_type: 'Apfelsorten', synonyms: [], images: [], + thumbnail_url: null, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }) diff --git a/frontend/src/views/FruitList.vue b/frontend/src/views/FruitList.vue index 4fd3e48..9414f6a 100644 --- a/frontend/src/views/FruitList.vue +++ b/frontend/src/views/FruitList.vue @@ -2,6 +2,7 @@ import { onMounted, onUnmounted, computed, ref } from 'vue' import { RouterLink } from 'vue-router' import { useFruitStore } from '../stores/fruitStore' +import FruitCard from '../components/FruitCard.vue' const SEARCH_TYPES = [ { label: '(Alle)', value: '' }, @@ -105,35 +106,15 @@ function onTypeChange() {
Wird geladen...
{{ store.error }}
- - - - - - - - - - - - - - - - - - -
NameOSDB-KürzelTyp
- Keine Früchte vorhanden. -
- - {{ fruit.name }} - - {{ fruit.osdb_number }}{{ fruit.fruit_type }}
+
+ Keine Früchte vorhanden. +
+
+ +
{{ store.total }} Früchte gesamt -- 2.54.0