# Spec: 07 · Improve Fruit Overview **Taiga Story:** #7 · `07-improve-fruit-overview` **Status:** Ready **Branch:** `feature/07-improve-fruit-overview` **Depends on:** `02-manage-fruits` (fruit_images), `04-fruit-publications` (publication_fruit_images) --- ## 1. Goal Replace the plain fruit list with styled cards showing title, OSDB ID, type, synonyms, and an auto-cropped thumbnail. Thumbnails are generated at upload time in Go (stdlib only) and stored as a separate DB column. A backfill endpoint handles existing images. --- ## 2. Background / Scope / Context / Constraints - Thumbnail selection priority: 1. Fruit's own image with `image_type = 'fruit'` (first found) 2. First `publication_fruit_images` entry linked to the fruit 3. No image → no thumbnail (graceful empty state) - Thumbnail constraint: image may be scaled down but MUST NOT be cropped (aspect ratio preserved). Empty borders allowed — no cutting. - Auto-crop algorithm: find bounding box of non-background content → add small padding → crop → re-encode as JPEG. Falls back to original if image decoding fails. - Thumbnail stored in `thumbnail_data BYTEA` column (nullable) on `fruit_images` and `publication_fruit_images`. - New serve endpoints for thumbnails, falling back to full `data` if `thumbnail_data` is NULL. - Backfill: HTTP endpoint `POST /api/v1/admin/backfill-thumbnails` (or CLI command) processes all rows without `thumbnail_data`. - No external libraries (OpenCV etc.) — Go stdlib only (`image/jpeg`, `image/png`). --- ## 3. Necessary Refactorings - Add `thumbnail_data` migration for both image tables. - On-upload path for both `fruit_images` and `publication_fruit_images`: run crop before INSERT. - `ListFruits` response: include `thumbnail_url` pointing to new `/thumbnail` endpoint. - `FruitList.vue`: replace table rows with card components. --- ## 4. Related Data Model ```sql -- Migration: 000004_add_thumbnails.up.sql ALTER TABLE fruit_images ADD COLUMN thumbnail_data BYTEA; ALTER TABLE publication_fruit_images ADD COLUMN thumbnail_data BYTEA; ``` --- ## 5. Affected Places / Related Components ### Backend | File | Change | |------|--------| | `backend/internal/imaging/crop.go` | Auto-crop function: `CropToContent(data []byte) ([]byte, error)` | | `backend/internal/repository/fruit_repo.go` | `CreateImage`: call `CropToContent`, store both `data` + `thumbnail_data` | | `backend/internal/repository/publication_repo.go` | Same for `publication_fruit_images` | | `backend/internal/handler/fruit_handler.go` | Add `GET /fruits/:id/images/:imageId/thumbnail` | | `backend/internal/handler/publication_handler.go` | Add `GET /publications/:id/fruit-images/:imgId/thumbnail` | | `backend/internal/handler/admin_handler.go` | `POST /api/v1/admin/backfill-thumbnails` | | `backend/cmd/server/router.go` | Register new routes | | `migrations/000004_add_thumbnails.up.sql` | ALTER TABLE | ### Crop Algorithm (Go stdlib) ```go // Pseudocode — no external deps func CropToContent(src []byte) ([]byte, error) { img, _, err := image.Decode(bytes.NewReader(src)) if err != nil { return src, nil } // fallback: return original bounds := img.Bounds() // Detect background: sample corner pixel as bg color bgColor := img.At(bounds.Min.X, bounds.Min.Y) // Find bounding box of non-bg pixels minX, minY, maxX, maxY := findContentBounds(img, bgColor, threshold=10) // Add padding (e.g. 5px each side) // Crop to bounding box // Encode as JPEG return encodedBytes, nil } ``` ### Frontend | File | Change | |------|--------| | `frontend/src/views/FruitList.vue` | Replace rows with `` components | | `frontend/src/components/FruitCard.vue` | Card: thumbnail + name + OSDB ID + type + synonyms | | `frontend/src/api/fruits.ts` | `listFruits` response includes `thumbnail_url` | ### API Changes | New endpoint | Description | |-------------|-------------| | `GET /api/v1/fruits/:id/images/:imageId/thumbnail` | Serves `thumbnail_data`, falls back to `data` | | `GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail` | Same | | `POST /api/v1/admin/backfill-thumbnails` | Processes all rows missing `thumbnail_data` | `ListFruits` response shape per fruit: ```json { "id": 1, "name": "Boskop", "osdb_number": "a001", "fruit_type": "Apfelsorten", "synonyms": ["Roter Boskop"], "thumbnail_url": "/api/v1/fruits/1/images/3/thumbnail" } ``` --- ## 6. Out of Scope - Interactive image editor / manual crop - WebP encoding - CDN / object storage for images - Pagination of fruit list --- ## 7. Verification | Check | Expected | |-------|---------| | Upload fruit image → `fruit_images.thumbnail_data` is non-null | Pass | | `GET /thumbnail` endpoint → responds with JPEG, smaller than original | Pass | | Backfill endpoint: runs on existing rows without thumbnail_data | All rows populated | | `GET /api/v1/fruits` → each fruit with image has `thumbnail_url` | Pass | | Fruit with no image → `thumbnail_url: null` | Graceful | | Thumbnail falls back to publication image if no direct image | Correct priority | | `FruitList.vue` shows cards with thumbnails | Visual check | | Image with undecodable format → fallback = original stored, no error | Pass | | `go test ./...` including imaging unit tests | Green | --- ## 8. Open Questions | # | Question | Impact | |---|----------|--------| | 1 | Background detection threshold: what color tolerance? | Crop quality | | 2 | Padding around cropped content: 5px? 10px? | Visual quality | | 3 | Should backfill endpoint be idempotent (skip rows already having thumbnail)? | Yes — only process WHERE thumbnail_data IS NULL | | 4 | Max concurrent backfill workers? | Performance | | 5 | Thumbnail max dimensions (e.g. 300×300 max)? | UX + storage |