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