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