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:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user