Author SHA1 Message Date
julia b6f2ac06ae Merge pull request 'feat: fruit overview with thumbnails and card layout (story #07)' (#7) from feature/07-improve-fruit-overview into main
Reviewed-on: #7
2026-06-19 07:19:00 +00:00
juliaandClaude Sonnet 4.6 649c9687ac 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>
2026-06-19 09:02:14 +02:00
julia 2525e8b68d Merge pull request 'feat: fruit search by name/synonym and type filter (story #06)' (#6) from feature/06-fruit-search into main
Reviewed-on: #6
2026-06-18 10:58:37 +00:00
juliaandClaude Sonnet 4.6 78c23557da feat: fruit search by name/synonym and type filter (story #06)
Backend: List repo query gains name (ILIKE with wildcard escaping on
name + synonym LEFT JOIN, SELECT DISTINCT) and types (ANY cast) params;
handler parses ?name= and ?type=, resolves combined-type aliases from
domain.FruitTypeAliases, validates plain types against validFruitTypes
to prevent Postgres enum cast errors. ORDER BY f.name, f.id for stable
pagination.

Frontend: listFruits gains optional {name, type} params; fruitStore adds
searchName/searchType state and setSearch action (resets offset, refetches);
FruitList.vue wires text input (debounced 300 ms + Enter) and flat type
dropdown (17 enum values + 4 aliases per spec §5 order); debounce timer
cleared on unmount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 12:46:38 +02:00
julia 50bfa30b06 Merge pull request 'feat: import existing publications from XML (story #05)' (#5) from feature/05-import-publications into main
Reviewed-on: #5
2026-06-18 10:20:01 +00:00
21 changed files with 857 additions and 75 deletions
+1
View File
@@ -7,6 +7,7 @@ A full-stack fruit-variety database (Go + Vue 3).
- Users can view a "Hello, OSDB!" landing page that confirms the backend is reachable via the Vite proxy. - Users can view a "Hello, OSDB!" landing page that confirms the backend is reachable via the Vite proxy.
- Users can create, view, edit, and delete fruit varieties, including managing synonyms and uploading images. - Users can create, view, edit, and delete fruit varieties, including managing synonyms and uploading images.
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`. - Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`.
- Users can search fruits by name or synonym (case-insensitive, debounced) and filter by type or combined-type alias (e.g. "Birnen- und Quittensorten") from the fruit list.
- Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images. - Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images.
## Quick Start ## Quick Start
+7
View File
@@ -39,6 +39,7 @@ func New(pool *pgxpool.Pool) *echo.Echo {
g.GET("/:id/images", fruits.ListImages) g.GET("/:id/images", fruits.ListImages)
g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M")) g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M"))
g.GET("/:id/images/:imageId", fruits.ServeImage) g.GET("/:id/images/:imageId", fruits.ServeImage)
g.GET("/:id/images/:imageId/thumbnail", fruits.ServeThumbnail)
g.DELETE("/:id/images/:imageId", fruits.DeleteImage) g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
// Publications (story #04) // Publications (story #04)
@@ -68,7 +69,13 @@ func New(pool *pgxpool.Pool) *echo.Echo {
p.GET("/:id/fruit-images", pubs.ListFruitImages) p.GET("/:id/fruit-images", pubs.ListFruitImages)
p.POST("/:id/fruit-images", pubs.UploadFruitImage, middleware.BodyLimit("5M")) p.POST("/:id/fruit-images", pubs.UploadFruitImage, middleware.BodyLimit("5M"))
p.GET("/:id/fruit-images/:imgId", pubs.ServeFruitImage) 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) 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 return e
} }
+9
View File
@@ -2,6 +2,14 @@ package domain
import "time" import "time"
// FruitTypeAliases maps combined-type alias labels to the enum values they expand to.
var FruitTypeAliases = map[string][]string{
"Birnen- und Quittensorten": {"Birnensorten", "Quittensorten"},
"Aprikosen und Pfirsiche": {"Aprikosen", "Pfirsiche"},
"Mirabellen und Reineclauden": {"Mirabellen", "Renekloden"},
"Pflaumen und Zwetschen": {"Pflaumen", "Zwetschen"},
}
type Fruit struct { type Fruit struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -10,6 +18,7 @@ type Fruit struct {
FruitType string `json:"fruit_type"` FruitType string `json:"fruit_type"`
Synonyms []string `json:"synonyms"` Synonyms []string `json:"synonyms"`
Images []FruitImage `json:"images"` Images []FruitImage `json:"images"`
ThumbnailURL *string `json:"thumbnail_url"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
+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,
})
}
+31 -2
View File
@@ -21,7 +21,7 @@ var (
// FruitRepository is the consumer-defined interface the handler depends on. // FruitRepository is the consumer-defined interface the handler depends on.
// The pg implementation in the repository package satisfies this structurally. // The pg implementation in the repository package satisfies this structurally.
type FruitRepository interface { type FruitRepository interface {
List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error)
Get(ctx context.Context, id int) (domain.Fruit, error) Get(ctx context.Context, id int) (domain.Fruit, error)
Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error) Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error)
Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error) Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error)
@@ -29,6 +29,7 @@ type FruitRepository interface {
ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error) 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) 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) 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 DeleteImage(ctx context.Context, fruitID, imageID int) error
} }
@@ -107,7 +108,18 @@ func (h *FruitHandler) List(c echo.Context) error {
} }
} }
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset) name := c.QueryParam("name")
var types []string
if typeParam := c.QueryParam("type"); typeParam != "" {
if expanded, ok := domain.FruitTypeAliases[typeParam]; ok {
types = expanded
} else if _, ok := validFruitTypes[typeParam]; ok {
types = []string{typeParam}
}
// unknown typeParam → types stays nil → no filter applied
}
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset, name, types)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
} }
@@ -280,3 +292,20 @@ func (h *FruitHandler) DeleteImage(c echo.Context) error {
} }
return c.NoContent(http.StatusNoContent) 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)
}
+105 -1
View File
@@ -52,9 +52,32 @@ func newFakeRepo() *fakeRepo {
} }
} }
func (r *fakeRepo) List(_ context.Context, limit, offset int) ([]domain.Fruit, int, error) { func (r *fakeRepo) List(_ context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
nameLower := strings.ToLower(name)
typeSet := make(map[string]struct{}, len(types))
for _, t := range types {
typeSet[t] = struct{}{}
}
all := make([]domain.Fruit, 0, len(r.fruits)) all := make([]domain.Fruit, 0, len(r.fruits))
for _, f := range r.fruits { for _, f := range r.fruits {
if name != "" {
nameMatch := strings.Contains(strings.ToLower(f.Name), nameLower)
synMatch := false
for _, s := range f.Synonyms {
if strings.Contains(strings.ToLower(s), nameLower) {
synMatch = true
break
}
}
if !nameMatch && !synMatch {
continue
}
}
if len(types) > 0 {
if _, ok := typeSet[f.FruitType]; !ok {
continue
}
}
all = append(all, f) all = append(all, f)
} }
total := len(all) total := len(all)
@@ -171,6 +194,14 @@ func (r *fakeRepo) GetImageData(_ context.Context, fruitID, imageID int) ([]byte
return r.imageData[imageID], nil 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 { func (r *fakeRepo) DeleteImage(_ context.Context, fruitID, imageID int) error {
img, ok := r.images[imageID] img, ok := r.images[imageID]
if !ok || img.FruitID != fruitID { if !ok || img.FruitID != fruitID {
@@ -241,6 +272,79 @@ func TestFruitList_WithItems(t *testing.T) {
} }
} }
func TestFruitList_FilterByName(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Cox Orange", OSDBNumber: "A002", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?name=Boskop", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Fatalf("want 1 result got %d", resp.Total)
}
if resp.Items[0].Name != "Boskop" {
t.Fatalf("want Boskop got %s", resp.Items[0].Name)
}
}
func TestFruitList_FilterByType(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Apfelsorten", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Fatalf("want 1 result got %d", resp.Total)
}
if resp.Items[0].FruitType != "Apfelsorten" {
t.Fatalf("want Apfelsorten got %s", resp.Items[0].FruitType)
}
}
func TestFruitList_AliasExpandsToMultipleTypes(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Quitte", OSDBNumber: "Q001", FruitType: "Quittensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[3] = domain.Fruit{ID: 3, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Birnen-+und+Quittensorten", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 2 {
t.Fatalf("want 2 results (Birnensorten + Quittensorten) got %d", resp.Total)
}
}
// -- Get -- // -- Get --
func TestFruitGet_Found(t *testing.T) { func TestFruitGet_Found(t *testing.T) {
@@ -42,6 +42,7 @@ type PublicationRepository interface {
ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error) ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error)
AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (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) 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 DeleteFruitImage(ctx context.Context, pubID, imgID int) error
GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, 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) 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 { func (h *PublicationHandler) DeleteFruitImage(c echo.Context) error {
pubID, err := parseID(c, "id") pubID, err := parseID(c, "id")
if err != nil { if err != nil {
@@ -265,6 +265,14 @@ func (r *fakePubRepo) GetFruitImageData(_ context.Context, pubID, imgID int) ([]
return r.fruitImgData[imgID], nil 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 { func (r *fakePubRepo) DeleteFruitImage(_ context.Context, pubID, imgID int) error {
img, ok := r.fruitImages[imgID] img, ok := r.fruitImages[imgID]
if !ok || img.PublicationID != pubID { 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")
}
}
+101 -9
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@@ -11,6 +12,7 @@ import (
"osdb/internal/domain" "osdb/internal/domain"
"osdb/internal/handler" "osdb/internal/handler"
"osdb/internal/imaging"
) )
type FruitRepo struct { type FruitRepo struct {
@@ -25,6 +27,10 @@ func imageURL(fruitID, imageID int) string {
return fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, imageID) 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 { func mapPgError(err error) error {
var pgErr *pgconn.PgError var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { if errors.As(err, &pgErr) && pgErr.Code == "23505" {
@@ -33,15 +39,42 @@ func mapPgError(err error) error {
return err return err
} }
func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) { func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
escaped := escapeLike(name)
countRow := r.pool.QueryRow(ctx,
`SELECT COUNT(DISTINCT f.id)
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 '\')
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))`,
escaped, types)
var total int var total int
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM fruits").Scan(&total); err != nil { if err := countRow.Scan(&total); err != nil {
return nil, 0, err return nil, 0, err
} }
rows, err := r.pool.Query(ctx, rows, err := r.pool.Query(ctx,
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at `SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at,
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset) 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 '\')
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))
ORDER BY f.name, f.id LIMIT $3 OFFSET $4`,
escaped, types, limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -50,10 +83,13 @@ func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit
fruits := []domain.Fruit{} fruits := []domain.Fruit{}
for rows.Next() { for rows.Next() {
var f domain.Fruit 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 return nil, 0, err
} }
if f.Synonyms == nil {
f.Synonyms = []string{} f.Synonyms = []string{}
}
f.Images = []domain.FruitImage{} f.Images = []domain.FruitImage{}
fruits = append(fruits, f) fruits = append(fruits, f)
} }
@@ -243,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) { 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 var exists bool
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil { if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
return domain.FruitImage{}, err return domain.FruitImage{}, err
@@ -252,12 +287,14 @@ func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string,
return domain.FruitImage{}, handler.ErrNotFound return domain.FruitImage{}, handler.ErrNotFound
} }
thumbnailData, _ := imaging.CropToContent(data)
var img domain.FruitImage var img domain.FruitImage
err := r.pool.QueryRow(ctx, err := r.pool.QueryRow(ctx,
`INSERT INTO fruit_images (fruit_id, filename, data, image_type, title) `INSERT INTO fruit_images (fruit_id, filename, data, thumbnail_data, image_type, title)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, fruit_id, filename, image_type, title, created_at`, 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) Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt)
if err != nil { if err != nil {
return domain.FruitImage{}, err return domain.FruitImage{}, err
@@ -279,6 +316,24 @@ func (r *FruitRepo) GetImageData(ctx context.Context, fruitID, imageID int) ([]b
return data, nil 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 { 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) tag, err := r.pool.Exec(ctx, `DELETE FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID)
if err != nil { if err != nil {
@@ -289,3 +344,40 @@ func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error
} }
return nil 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
}
@@ -97,7 +97,7 @@ func TestFruitRepoIntegration(t *testing.T) {
} }
// List // List
fruits, total, err := repo.List(ctx, 50, 0) fruits, total, err := repo.List(ctx, 50, 0, "", nil)
if err != nil { if err != nil {
t.Fatalf("List: %v", err) t.Fatalf("List: %v", err)
} }
@@ -10,6 +10,7 @@ import (
"osdb/internal/domain" "osdb/internal/domain"
"osdb/internal/handler" "osdb/internal/handler"
"osdb/internal/imaging"
) )
type PublicationRepo struct { 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 { if err := r.requirePublication(ctx, pubID); err != nil {
return domain.PublicationFruitImage{}, err return domain.PublicationFruitImage{}, err
} }
thumbnailData, _ := imaging.CropToContent(data)
var img domain.PublicationFruitImage var img domain.PublicationFruitImage
err := r.pool.QueryRow(ctx, err := r.pool.QueryRow(ctx,
`INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, image_type) `INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, thumbnail_data, image_type)
VALUES ($1, $2, $3, $4, 'fruit') VALUES ($1, $2, $3, $4, $5, 'fruit')
RETURNING id, publication_id, fruit_id, filename, image_type, created_at`, 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) Scan(&img.ID, &img.PublicationID, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt)
if err != nil { if err != nil {
return domain.PublicationFruitImage{}, err return domain.PublicationFruitImage{}, err
@@ -357,6 +361,24 @@ func (r *PublicationRepo) GetFruitImageData(ctx context.Context, pubID, imgID in
return data, err 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 { func (r *PublicationRepo) DeleteFruitImage(ctx context.Context, pubID, imgID int) error {
tag, err := r.pool.Exec(ctx, tag, err := r.pool.Exec(ctx,
`DELETE FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`, imgID, pubID) `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 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;
+19 -10
View File
@@ -19,18 +19,17 @@ type MockResponse = {
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => { const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
const method = init?.method ?? 'GET' const method = init?.method ?? 'GET'
if (url === '/api/v1/fruits?limit=50&offset=0' && method === 'GET') { if (url.startsWith('/api/v1/fruits?') && method === 'GET') {
const params = new URLSearchParams(url.split('?')[1])
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }), json: async () => ({
}) items: [],
} total: 0,
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') { limit: Number(params.get('limit') ?? 50),
return Promise.resolve({ offset: Number(params.get('offset') ?? 0),
ok: true, }),
status: 200,
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
}) })
} }
if (url === '/api/v1/fruits/1' && method === 'GET') { if (url === '/api/v1/fruits/1' && method === 'GET') {
@@ -99,9 +98,19 @@ describe('listFruits', () => {
}) })
it('passes custom limit and offset', async () => { it('passes custom limit and offset', async () => {
const result = await listFruits(10, 20) const result = await listFruits({ limit: 10, offset: 20 })
expect(result.offset).toBe(20) expect(result.offset).toBe(20)
}) })
it('appends name param when provided', async () => {
await listFruits({ name: 'Boskop' })
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('name=Boskop'))
})
it('appends type param when provided', async () => {
await listFruits({ type: 'Apfelsorten' })
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('type=Apfelsorten'))
})
}) })
describe('getFruit', () => { describe('getFruit', () => {
+12 -2
View File
@@ -38,6 +38,7 @@ export interface Fruit {
fruit_type: string fruit_type: string
synonyms: string[] synonyms: string[]
images: FruitImage[] images: FruitImage[]
thumbnail_url: string | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -77,8 +78,17 @@ async function checkOk(res: Response): Promise<Response> {
return res return res
} }
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> { export async function listFruits(params?: {
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`) limit?: number
offset?: number
name?: string
type?: string
}): Promise<FruitListResponse> {
const { limit = 50, offset = 0, name, type } = params ?? {}
let url = `/api/v1/fruits?limit=${limit}&offset=${offset}`
if (name) url += `&name=${encodeURIComponent(name)}`
if (type) url += `&type=${encodeURIComponent(type)}`
const res = await fetch(url)
return (await checkOk(res)).json() return (await checkOk(res)).json()
} }
+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>
+12
View File
@@ -11,6 +11,7 @@ const makeFruit = (id: number, name = 'Boskop'): Fruit => ({
fruit_type: 'Apfelsorten', fruit_type: 'Apfelsorten',
synonyms: [], synonyms: [],
images: [], images: [],
thumbnail_url: null,
created_at: '2024-01-01T00:00:00Z', created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
}) })
@@ -115,4 +116,15 @@ describe('fruitStore', () => {
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined() expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
expect(store.total).toBe(1) expect(store.total).toBe(1)
}) })
it('setSearch updates search state and resets offset', async () => {
const store = useFruitStore()
await store.fetchFruits()
store.offset = 50
await store.setSearch('Boskop', 'Apfelsorten')
expect(store.searchName).toBe('Boskop')
expect(store.searchType).toBe('Apfelsorten')
expect(store.offset).toBe(0)
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('name=Boskop'))
})
}) })
+16 -2
View File
@@ -18,12 +18,19 @@ export const useFruitStore = defineStore('fruit', () => {
const current = ref<Fruit | null>(null) const current = ref<Fruit | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const searchName = ref('')
const searchType = ref('')
async function fetchFruits(lim = limit.value, off = offset.value) { async function fetchFruits(lim = limit.value, off = offset.value) {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const resp = await listFruits(lim, off) const resp = await listFruits({
limit: lim,
offset: off,
name: searchName.value || undefined,
type: searchType.value || undefined,
})
fruits.value = resp.items fruits.value = resp.items
total.value = resp.total total.value = resp.total
limit.value = resp.limit limit.value = resp.limit
@@ -35,6 +42,13 @@ export const useFruitStore = defineStore('fruit', () => {
} }
} }
async function setSearch(name: string, type: string) {
searchName.value = name
searchType.value = type
offset.value = 0
await fetchFruits(limit.value, 0)
}
async function fetchFruit(id: number) { async function fetchFruit(id: number) {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -71,5 +85,5 @@ export const useFruitStore = defineStore('fruit', () => {
if (current.value?.id === id) current.value = null if (current.value?.id === id) current.value = null
} }
return { fruits, total, limit, offset, current, loading, error, fetchFruits, fetchFruit, create, update, remove } return { fruits, total, limit, offset, current, loading, error, searchName, searchType, fetchFruits, fetchFruit, create, update, remove, setSearch }
}) })
+73 -33
View File
@@ -1,14 +1,47 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, computed } from 'vue' import { onMounted, onUnmounted, computed, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore' import { useFruitStore } from '../stores/fruitStore'
import FruitCard from '../components/FruitCard.vue'
const SEARCH_TYPES = [
{ label: '(Alle)', value: '' },
{ label: 'Apfelsorten', value: 'Apfelsorten' },
{ label: 'Birnensorten', value: 'Birnensorten' },
{ label: 'Quittensorten', value: 'Quittensorten' },
{ label: 'Birnen- und Quittensorten', value: 'Birnen- und Quittensorten' },
{ label: 'Aprikosen', value: 'Aprikosen' },
{ label: 'Pfirsiche', value: 'Pfirsiche' },
{ label: 'Aprikosen und Pfirsiche', value: 'Aprikosen und Pfirsiche' },
{ label: 'Mirabellen', value: 'Mirabellen' },
{ label: 'Renekloden', value: 'Renekloden' },
{ label: 'Mirabellen und Reineclauden', value: 'Mirabellen und Reineclauden' },
{ label: 'Pflaumen', value: 'Pflaumen' },
{ label: 'Zwetschen', value: 'Zwetschen' },
{ label: 'Pflaumen und Zwetschen', value: 'Pflaumen und Zwetschen' },
{ label: 'Sauerkirschen', value: 'Sauerkirschen' },
{ label: 'Süßkirschen', value: 'Süßkirschen' },
{ label: 'Brombeeren', value: 'Brombeeren' },
{ label: 'Erdbeeren', value: 'Erdbeeren' },
{ label: 'Himbeeren', value: 'Himbeeren' },
{ label: 'Johannisbeeren', value: 'Johannisbeeren' },
{ label: 'Stachelbeeren', value: 'Stachelbeeren' },
{ label: 'Wein', value: 'Wein' },
]
const store = useFruitStore() const store = useFruitStore()
const nameInput = ref(store.searchName)
const typeSelect = ref(store.searchType)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => { onMounted(async () => {
await store.fetchFruits() await store.fetchFruits()
}) })
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
const hasPrev = computed(() => store.offset > 0) const hasPrev = computed(() => store.offset > 0)
const hasNext = computed(() => store.offset + store.limit < store.total) const hasNext = computed(() => store.offset + store.limit < store.total)
@@ -19,6 +52,23 @@ async function prev() {
async function next() { async function next() {
await store.fetchFruits(store.limit, store.offset + store.limit) await store.fetchFruits(store.limit, store.offset + store.limit)
} }
function onNameInput() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
store.setSearch(nameInput.value, typeSelect.value)
}, 300)
}
function onNameEnter() {
if (debounceTimer) clearTimeout(debounceTimer)
store.setSearch(nameInput.value, typeSelect.value)
}
function onTypeChange() {
if (debounceTimer) clearTimeout(debounceTimer)
store.setSearch(nameInput.value, typeSelect.value)
}
</script> </script>
<template> <template>
@@ -33,48 +83,38 @@ async function next() {
</RouterLink> </RouterLink>
</div> </div>
<!-- Search placeholder (story #06) --> <div class="mb-4 flex gap-3">
<div class="mb-4">
<input <input
v-model="nameInput"
type="text" type="text"
placeholder="Suche... (kommt in Story #06)" placeholder="Name oder Synonym suchen..."
disabled class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed" @input="onNameInput"
@keydown.enter="onNameEnter"
/> />
<select
v-model="typeSelect"
class="border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
@change="onTypeChange"
>
<option v-for="opt in SEARCH_TYPES" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div> </div>
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div> <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-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else> <div v-else>
<table class="w-full border-collapse border border-gray-200 rounded"> <div v-if="store.fruits.length === 0" class="text-center text-gray-400 py-16">
<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. Keine Früchte vorhanden.
</td> </div>
</tr> <div
<tr v-else
v-for="fruit in store.fruits" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
:key="fruit.id"
class="hover:bg-gray-50 cursor-pointer"
> >
<td class="border border-gray-200 px-4 py-2"> <FruitCard v-for="fruit in store.fruits" :key="fruit.id" :fruit="fruit" />
<RouterLink :to="`/fruits/${fruit.id}`" class="text-green-700 hover:underline"> </div>
{{ 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 class="flex items-center justify-between mt-4 text-sm text-gray-600"> <div class="flex items-center justify-between mt-4 text-sm text-gray-600">
<span>{{ store.total }} Früchte gesamt</span> <span>{{ store.total }} Früchte gesamt</span>