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
+7
View File
@@ -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
}
+10 -9
View File
@@ -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 {
+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 {
+137
View File
@@ -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
}
+153
View File
@@ -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")
}
}
+78 -7
View File
@@ -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
}
@@ -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
}
@@ -0,0 +1,2 @@
ALTER TABLE fruit_images DROP COLUMN thumbnail_data;
ALTER TABLE publication_fruit_images DROP COLUMN thumbnail_data;
@@ -0,0 +1,2 @@
ALTER TABLE fruit_images ADD COLUMN thumbnail_data BYTEA;
ALTER TABLE publication_fruit_images ADD COLUMN thumbnail_data BYTEA;
+1
View File
@@ -38,6 +38,7 @@ export interface Fruit {
fruit_type: string
synonyms: string[]
images: FruitImage[]
thumbnail_url: string | null
created_at: string
updated_at: string
}
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import type { Fruit } from '../api/fruits'
defineProps<{ fruit: Fruit }>()
</script>
<template>
<RouterLink
:to="`/fruits/${fruit.id}`"
class="block bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow"
>
<div class="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
<img
v-if="fruit.thumbnail_url"
:src="fruit.thumbnail_url"
:alt="fruit.name"
class="max-w-full max-h-full object-contain"
/>
<span v-else class="text-gray-300 text-4xl select-none">🌿</span>
</div>
<div class="p-3">
<p class="font-semibold text-gray-900 truncate">{{ fruit.name }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ fruit.osdb_number }}</p>
<p class="text-xs text-green-700 mt-0.5">{{ fruit.fruit_type }}</p>
<p v-if="fruit.synonyms.length" class="text-xs text-gray-400 mt-1 truncate">
{{ fruit.synonyms.join(', ') }}
</p>
</div>
</RouterLink>
</template>
+1
View File
@@ -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',
})
+10 -29
View File
@@ -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() {
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else>
<table class="w-full border-collapse border border-gray-200 rounded">
<thead class="bg-gray-50">
<tr>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Name</th>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">OSDB-Kürzel</th>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Typ</th>
</tr>
</thead>
<tbody>
<tr v-if="store.fruits.length === 0">
<td colspan="3" class="border border-gray-200 px-4 py-8 text-center text-gray-400">
Keine Früchte vorhanden.
</td>
</tr>
<tr
v-for="fruit in store.fruits"
:key="fruit.id"
class="hover:bg-gray-50 cursor-pointer"
>
<td class="border border-gray-200 px-4 py-2">
<RouterLink :to="`/fruits/${fruit.id}`" class="text-green-700 hover:underline">
{{ fruit.name }}
</RouterLink>
</td>
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.osdb_number }}</td>
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.fruit_type }}</td>
</tr>
</tbody>
</table>
<div v-if="store.fruits.length === 0" class="text-center text-gray-400 py-16">
Keine Früchte vorhanden.
</div>
<div
v-else
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
>
<FruitCard v-for="fruit in store.fruits" :key="fruit.id" :fruit="fruit" />
</div>
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
<span>{{ store.total }} Früchte gesamt</span>