Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
649c9687ac | ||
|
|
2525e8b68d | ||
|
|
78c23557da | ||
|
|
50bfa30b06 |
@@ -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 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`.
|
||||
- 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.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,14 @@ package domain
|
||||
|
||||
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 {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -10,6 +18,7 @@ type Fruit struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -21,7 +21,7 @@ var (
|
||||
// FruitRepository is the consumer-defined interface the handler depends on.
|
||||
// The pg implementation in the repository package satisfies this structurally.
|
||||
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)
|
||||
Create(ctx context.Context, 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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
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)
|
||||
}
|
||||
total := len(all)
|
||||
@@ -171,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 {
|
||||
@@ -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 --
|
||||
|
||||
func TestFruitGet_Found(t *testing.T) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/handler"
|
||||
"osdb/internal/imaging"
|
||||
)
|
||||
|
||||
type FruitRepo struct {
|
||||
@@ -25,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" {
|
||||
@@ -33,15 +39,42 @@ func mapPgError(err error) error {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
|
||||
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset)
|
||||
`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 '\')
|
||||
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 {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -50,10 +83,13 @@ func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit
|
||||
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
|
||||
}
|
||||
if f.Synonyms == nil {
|
||||
f.Synonyms = []string{}
|
||||
}
|
||||
f.Images = []domain.FruitImage{}
|
||||
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) {
|
||||
// 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
|
||||
@@ -252,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
|
||||
@@ -279,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 {
|
||||
@@ -289,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
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestFruitRepoIntegration(t *testing.T) {
|
||||
}
|
||||
|
||||
// List
|
||||
fruits, total, err := repo.List(ctx, 50, 0)
|
||||
fruits, total, err := repo.List(ctx, 50, 0, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -19,18 +19,17 @@ type MockResponse = {
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
|
||||
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({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
|
||||
json: async () => ({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: Number(params.get('limit') ?? 50),
|
||||
offset: Number(params.get('offset') ?? 0),
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/fruits/1' && method === 'GET') {
|
||||
@@ -99,9 +98,19 @@ describe('listFruits', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface Fruit {
|
||||
fruit_type: string
|
||||
synonyms: string[]
|
||||
images: FruitImage[]
|
||||
thumbnail_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -77,8 +78,17 @@ async function checkOk(res: Response): Promise<Response> {
|
||||
return res
|
||||
}
|
||||
|
||||
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> {
|
||||
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`)
|
||||
export async function listFruits(params?: {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
})
|
||||
@@ -115,4 +116,15 @@ describe('fruitStore', () => {
|
||||
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
|
||||
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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,12 +18,19 @@ export const useFruitStore = defineStore('fruit', () => {
|
||||
const current = ref<Fruit | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const searchName = ref('')
|
||||
const searchType = ref('')
|
||||
|
||||
async function fetchFruits(lim = limit.value, off = offset.value) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
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
|
||||
total.value = resp.total
|
||||
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) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -71,5 +85,5 @@ export const useFruitStore = defineStore('fruit', () => {
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -1,14 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
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: '' },
|
||||
{ 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 nameInput = ref(store.searchName)
|
||||
const typeSelect = ref(store.searchType)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchFruits()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
const hasPrev = computed(() => store.offset > 0)
|
||||
const hasNext = computed(() => store.offset + store.limit < store.total)
|
||||
|
||||
@@ -19,6 +52,23 @@ async function prev() {
|
||||
async function next() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -33,48 +83,38 @@ async function next() {
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Search placeholder (story #06) -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 flex gap-3">
|
||||
<input
|
||||
v-model="nameInput"
|
||||
type="text"
|
||||
placeholder="Suche... (kommt in Story #06)"
|
||||
disabled
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed"
|
||||
placeholder="Name oder Synonym suchen..."
|
||||
class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
@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 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">
|
||||
<div v-if="store.fruits.length === 0" class="text-center text-gray-400 py-16">
|
||||
Keine Früchte vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="fruit in store.fruits"
|
||||
:key="fruit.id"
|
||||
class="hover:bg-gray-50 cursor-pointer"
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user