feat: fruit overview with thumbnails and card layout (story #07)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 09:02:14 +02:00
co-authored by Claude Sonnet 4.6
parent 2525e8b68d
commit 649c9687ac
17 changed files with 591 additions and 48 deletions
+44
View File
@@ -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,
})
}
+18
View File
@@ -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)
}
@@ -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 {
@@ -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 {
@@ -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 {