Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce4d67cbe |
@@ -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`.
|
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -41,5 +41,34 @@ func New(pool *pgxpool.Pool) *echo.Echo {
|
|||||||
g.GET("/:id/images/:imageId", fruits.ServeImage)
|
g.GET("/:id/images/:imageId", fruits.ServeImage)
|
||||||
g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
|
g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
|
||||||
|
|
||||||
|
// Publications (story #04)
|
||||||
|
pubRepo := repository.NewPublicationRepo(pool)
|
||||||
|
pubs := handler.NewPublicationHandler(pubRepo)
|
||||||
|
|
||||||
|
// Fruit-scoped publication reads
|
||||||
|
g.GET("/:id/descriptions", pubs.GetFruitDescriptions)
|
||||||
|
g.GET("/:id/publication-images", pubs.GetFruitPublicationImages)
|
||||||
|
|
||||||
|
p := api.Group("/publications")
|
||||||
|
p.GET("", pubs.List)
|
||||||
|
p.POST("", pubs.Create)
|
||||||
|
p.GET("/:id", pubs.Get)
|
||||||
|
p.PUT("/:id", pubs.Update)
|
||||||
|
p.DELETE("/:id", pubs.Delete)
|
||||||
|
p.POST("/:id/image", pubs.UploadCoverImage, middleware.BodyLimit("5M"))
|
||||||
|
p.GET("/:id/image", pubs.ServeCoverImage)
|
||||||
|
p.DELETE("/:id/image", pubs.DeleteCoverImage)
|
||||||
|
p.GET("/:id/fruits", pubs.ListFruits)
|
||||||
|
p.POST("/:id/fruits", pubs.LinkFruit)
|
||||||
|
p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit)
|
||||||
|
p.GET("/:id/descriptions", pubs.ListDescriptions)
|
||||||
|
p.POST("/:id/descriptions", pubs.UploadDescription, middleware.BodyLimit("5M"))
|
||||||
|
p.GET("/:id/descriptions/:descId", pubs.ServeDescription)
|
||||||
|
p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription)
|
||||||
|
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.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage)
|
||||||
|
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Publication struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author *string `json:"author"`
|
||||||
|
OSDBPubID string `json:"osdb_pub_id"`
|
||||||
|
ImageURL *string `json:"image_url,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicationWriteDTO struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author *string `json:"author"`
|
||||||
|
OSDBPubID string `json:"osdb_pub_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicationFruit struct {
|
||||||
|
FruitID int `json:"fruit_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
OSDBNumber string `json:"osdb_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicationDescription struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
PublicationID int `json:"publication_id"`
|
||||||
|
FruitID int `json:"fruit_id"`
|
||||||
|
FruitName string `json:"fruit_name"`
|
||||||
|
PublicationTitle string `json:"publication_title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicationFruitImage struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
PublicationID int `json:"publication_id"`
|
||||||
|
PublicationTitle string `json:"publication_title"`
|
||||||
|
PublicationAuthor *string `json:"publication_author"`
|
||||||
|
FruitID int `json:"fruit_id"`
|
||||||
|
Filename *string `json:"filename"`
|
||||||
|
ImageType string `json:"image_type"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"osdb/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDuplicateOSDBPubID = errors.New("duplicate osdb_pub_id")
|
||||||
|
ErrDuplicatePubDescription = errors.New("duplicate publication description for this fruit")
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicationRepository is the consumer-defined interface the handler depends on.
|
||||||
|
type PublicationRepository interface {
|
||||||
|
List(ctx context.Context) ([]domain.Publication, error)
|
||||||
|
Get(ctx context.Context, id int) (domain.Publication, error)
|
||||||
|
Create(ctx context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error)
|
||||||
|
Update(ctx context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error)
|
||||||
|
Delete(ctx context.Context, id int) error
|
||||||
|
|
||||||
|
SetImage(ctx context.Context, pubID int, data []byte) error
|
||||||
|
GetImageData(ctx context.Context, pubID int) ([]byte, error)
|
||||||
|
DeleteImage(ctx context.Context, pubID int) error
|
||||||
|
|
||||||
|
ListFruits(ctx context.Context, pubID int) ([]domain.PublicationFruit, error)
|
||||||
|
LinkFruit(ctx context.Context, pubID, fruitID int) error
|
||||||
|
UnlinkFruit(ctx context.Context, pubID, fruitID int) error
|
||||||
|
|
||||||
|
ListDescriptions(ctx context.Context, pubID int) ([]domain.PublicationDescription, error)
|
||||||
|
AddDescription(ctx context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error)
|
||||||
|
GetDescriptionData(ctx context.Context, pubID, descID int) ([]byte, error)
|
||||||
|
DeleteDescription(ctx context.Context, pubID, descID int) error
|
||||||
|
|
||||||
|
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)
|
||||||
|
DeleteFruitImage(ctx context.Context, pubID, imgID int) error
|
||||||
|
|
||||||
|
GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error)
|
||||||
|
GetFruitPublicationImages(ctx context.Context, fruitID int) ([]domain.PublicationFruitImage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicationHandler struct {
|
||||||
|
repo PublicationRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublicationHandler(repo PublicationRepository) *PublicationHandler {
|
||||||
|
return &PublicationHandler{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePublication(dto domain.PublicationWriteDTO) []string {
|
||||||
|
var errs []string
|
||||||
|
if strings.TrimSpace(dto.Title) == "" {
|
||||||
|
errs = append(errs, "title is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(dto.OSDBPubID) == "" {
|
||||||
|
errs = append(errs, "osdb_pub_id is required")
|
||||||
|
}
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPubRepoError(c echo.Context, err error) error {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
|
||||||
|
case errors.Is(err, ErrDuplicateOSDBPubID):
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": "osdb_pub_id already exists"})
|
||||||
|
case errors.Is(err, ErrDuplicatePubDescription):
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": "description already exists for this fruit in this publication"})
|
||||||
|
default:
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) List(c echo.Context) error {
|
||||||
|
pubs, err := h.repo.List(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
if pubs == nil {
|
||||||
|
pubs = []domain.Publication{}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, pubs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) Get(c echo.Context) error {
|
||||||
|
id, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, err := h.repo.Get(c.Request().Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) Create(c echo.Context) error {
|
||||||
|
var dto domain.PublicationWriteDTO
|
||||||
|
if err := c.Bind(&dto); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
if errs := validatePublication(dto); len(errs) > 0 {
|
||||||
|
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
|
||||||
|
}
|
||||||
|
pub, err := h.repo.Create(c.Request().Context(), dto)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) Update(c echo.Context) error {
|
||||||
|
id, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var dto domain.PublicationWriteDTO
|
||||||
|
if err := c.Bind(&dto); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||||
|
}
|
||||||
|
if errs := validatePublication(dto); len(errs) > 0 {
|
||||||
|
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
|
||||||
|
}
|
||||||
|
pub, err := h.repo.Update(c.Request().Context(), id, dto)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, pub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) Delete(c echo.Context) error {
|
||||||
|
id, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.repo.Delete(c.Request().Context(), id); err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) UploadCoverImage(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file, err := c.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
|
||||||
|
}
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
data, err := io.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
if err := h.repo.SetImage(c.Request().Context(), pubID, data); err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, map[string]string{"message": "image uploaded"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) ServeCoverImage(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := h.repo.GetImageData(c.Request().Context(), pubID)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
contentType := http.DetectContentType(data)
|
||||||
|
return c.Blob(http.StatusOK, contentType, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) DeleteCoverImage(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteImage(c.Request().Context(), pubID); err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) ListFruits(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fruits, err := h.repo.ListFruits(c.Request().Context(), pubID)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
if fruits == nil {
|
||||||
|
fruits = []domain.PublicationFruit{}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, fruits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) LinkFruit(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
FruitID int `json:"fruit_id"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil || body.FruitID == 0 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
|
||||||
|
}
|
||||||
|
if err := h.repo.LinkFruit(c.Request().Context(), pubID, body.FruitID); err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, map[string]int{"fruit_id": body.FruitID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) UnlinkFruit(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fruitID, err := parseID(c, "fruitId")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.repo.UnlinkFruit(c.Request().Context(), pubID, fruitID); err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) ListDescriptions(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
descs, err := h.repo.ListDescriptions(c.Request().Context(), pubID)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
if descs == nil {
|
||||||
|
descs = []domain.PublicationDescription{}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, descs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) UploadDescription(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fruitIDStr := c.FormValue("fruit_id")
|
||||||
|
fruitID, convErr := strconv.Atoi(fruitIDStr)
|
||||||
|
if convErr != nil || fruitID == 0 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
|
||||||
|
}
|
||||||
|
file, err := c.FormFile("pdf")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "pdf file is required"})
|
||||||
|
}
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
data, err := io.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
desc, err := h.repo.AddDescription(c.Request().Context(), pubID, fruitID, data)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) ServeDescription(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
descID, err := parseID(c, "descId")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := h.repo.GetDescriptionData(c.Request().Context(), pubID, descID)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.Blob(http.StatusOK, "application/pdf", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) DeleteDescription(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
descID, err := parseID(c, "descId")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteDescription(c.Request().Context(), pubID, descID); err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) ListFruitImages(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imgs, err := h.repo.ListFruitImages(c.Request().Context(), pubID)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
if imgs == nil {
|
||||||
|
imgs = []domain.PublicationFruitImage{}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, imgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) UploadFruitImage(c echo.Context) error {
|
||||||
|
pubID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fruitIDStr := c.FormValue("fruit_id")
|
||||||
|
fruitID, convErr := strconv.Atoi(fruitIDStr)
|
||||||
|
if convErr != nil || fruitID == 0 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
|
||||||
|
}
|
||||||
|
file, err := c.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
|
||||||
|
}
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
data, err := io.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
var filename *string
|
||||||
|
if file.Filename != "" {
|
||||||
|
s := file.Filename
|
||||||
|
filename = &s
|
||||||
|
}
|
||||||
|
img, err := h.repo.AddFruitImage(c.Request().Context(), pubID, fruitID, filename, data)
|
||||||
|
if err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, img)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) ServeFruitImage(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.GetFruitImageData(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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imgID, err := parseID(c, "imgId")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteFruitImage(c.Request().Context(), pubID, imgID); err != nil {
|
||||||
|
return mapPubRepoError(c, err)
|
||||||
|
}
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) GetFruitDescriptions(c echo.Context) error {
|
||||||
|
fruitID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
descs, err := h.repo.GetFruitDescriptions(c.Request().Context(), fruitID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
if descs == nil {
|
||||||
|
descs = []domain.PublicationDescription{}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, descs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *PublicationHandler) GetFruitPublicationImages(c echo.Context) error {
|
||||||
|
fruitID, err := parseID(c, "id")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imgs, err := h.repo.GetFruitPublicationImages(c.Request().Context(), fruitID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
|
}
|
||||||
|
if imgs == nil {
|
||||||
|
imgs = []domain.PublicationFruitImage{}
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, imgs)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,428 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"osdb/internal/domain"
|
||||||
|
"osdb/internal/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PublicationRepo struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPublicationRepo(pool *pgxpool.Pool) *PublicationRepo {
|
||||||
|
return &PublicationRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pubImageURL(pubID int) string {
|
||||||
|
return fmt.Sprintf("/api/v1/publications/%d/image", pubID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func descURL(pubID, descID int) string {
|
||||||
|
return fmt.Sprintf("/api/v1/publications/%d/descriptions/%d", pubID, descID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pubFruitImgURL(pubID, imgID int) string {
|
||||||
|
return fmt.Sprintf("/api/v1/publications/%d/fruit-images/%d", pubID, imgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPubPgError(err error) error {
|
||||||
|
return mapPgError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPgErrorToPub(err error) error {
|
||||||
|
e := mapPgError(err)
|
||||||
|
if errors.Is(e, handler.ErrDuplicateOSDBNumber) {
|
||||||
|
return handler.ErrDuplicateOSDBPubID
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapPgErrorToDesc(err error) error {
|
||||||
|
e := mapPgError(err)
|
||||||
|
if errors.Is(e, handler.ErrDuplicateOSDBNumber) {
|
||||||
|
return handler.ErrDuplicatePubDescription
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) List(ctx context.Context) ([]domain.Publication, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT id, title, author, osdb_pub_id,
|
||||||
|
CASE WHEN image_data IS NOT NULL THEN true ELSE false END AS has_image,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM publications ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
pubs := []domain.Publication{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p domain.Publication
|
||||||
|
var hasImage bool
|
||||||
|
if err := rows.Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hasImage {
|
||||||
|
url := pubImageURL(p.ID)
|
||||||
|
p.ImageURL = &url
|
||||||
|
}
|
||||||
|
pubs = append(pubs, p)
|
||||||
|
}
|
||||||
|
return pubs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) Get(ctx context.Context, id int) (domain.Publication, error) {
|
||||||
|
var p domain.Publication
|
||||||
|
var hasImage bool
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT id, title, author, osdb_pub_id,
|
||||||
|
CASE WHEN image_data IS NOT NULL THEN true ELSE false END,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM publications WHERE id=$1`, id).
|
||||||
|
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.Publication{}, handler.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.Publication{}, err
|
||||||
|
}
|
||||||
|
if hasImage {
|
||||||
|
url := pubImageURL(p.ID)
|
||||||
|
p.ImageURL = &url
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) Create(ctx context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error) {
|
||||||
|
var p domain.Publication
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO publications (title, author, osdb_pub_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, title, author, osdb_pub_id, created_at, updated_at`,
|
||||||
|
dto.Title, dto.Author, dto.OSDBPubID).
|
||||||
|
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Publication{}, mapPgErrorToPub(err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) Update(ctx context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error) {
|
||||||
|
var p domain.Publication
|
||||||
|
var hasImage bool
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`UPDATE publications SET title=$1, author=$2, osdb_pub_id=$3, updated_at=NOW()
|
||||||
|
WHERE id=$4
|
||||||
|
RETURNING id, title, author, osdb_pub_id,
|
||||||
|
CASE WHEN image_data IS NOT NULL THEN true ELSE false END,
|
||||||
|
created_at, updated_at`,
|
||||||
|
dto.Title, dto.Author, dto.OSDBPubID, id).
|
||||||
|
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.Publication{}, handler.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.Publication{}, mapPgErrorToPub(err)
|
||||||
|
}
|
||||||
|
if hasImage {
|
||||||
|
url := pubImageURL(p.ID)
|
||||||
|
p.ImageURL = &url
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) Delete(ctx context.Context, id int) error {
|
||||||
|
tag, err := r.pool.Exec(ctx, `DELETE FROM publications WHERE id=$1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) SetImage(ctx context.Context, pubID int, data []byte) error {
|
||||||
|
tag, err := r.pool.Exec(ctx,
|
||||||
|
`UPDATE publications SET image_data=$1, updated_at=NOW() WHERE id=$2`, data, pubID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) GetImageData(ctx context.Context, pubID int) ([]byte, error) {
|
||||||
|
var data []byte
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT image_data FROM publications WHERE id=$1`, pubID).Scan(&data)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, handler.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
return nil, handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) DeleteImage(ctx context.Context, pubID int) error {
|
||||||
|
var data []byte
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`UPDATE publications SET image_data=NULL, updated_at=NOW() WHERE id=$1 RETURNING image_data`, pubID).
|
||||||
|
Scan(&data)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) ListFruits(ctx context.Context, pubID int) ([]domain.PublicationFruit, error) {
|
||||||
|
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT f.id, f.name, f.osdb_number
|
||||||
|
FROM fruits f
|
||||||
|
JOIN publication_fruits pf ON pf.fruit_id = f.id
|
||||||
|
WHERE pf.publication_id=$1 ORDER BY f.name`, pubID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
fruits := []domain.PublicationFruit{}
|
||||||
|
for rows.Next() {
|
||||||
|
var pf domain.PublicationFruit
|
||||||
|
if err := rows.Scan(&pf.FruitID, &pf.Name, &pf.OSDBNumber); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fruits = append(fruits, pf)
|
||||||
|
}
|
||||||
|
return fruits, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) LinkFruit(ctx context.Context, pubID, fruitID int) error {
|
||||||
|
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`INSERT INTO publication_fruits (publication_id, fruit_id) VALUES ($1, $2)
|
||||||
|
ON CONFLICT DO NOTHING`, pubID, fruitID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) UnlinkFruit(ctx context.Context, pubID, fruitID int) error {
|
||||||
|
tag, err := r.pool.Exec(ctx,
|
||||||
|
`DELETE FROM publication_fruits WHERE publication_id=$1 AND fruit_id=$2`, pubID, fruitID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) ListDescriptions(ctx context.Context, pubID int) ([]domain.PublicationDescription, error) {
|
||||||
|
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT pd.id, pd.publication_id, pd.fruit_id, f.name, p.title, pd.created_at
|
||||||
|
FROM publication_descriptions pd
|
||||||
|
JOIN fruits f ON f.id = pd.fruit_id
|
||||||
|
JOIN publications p ON p.id = pd.publication_id
|
||||||
|
WHERE pd.publication_id=$1 ORDER BY pd.id`, pubID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
descs := []domain.PublicationDescription{}
|
||||||
|
for rows.Next() {
|
||||||
|
var d domain.PublicationDescription
|
||||||
|
if err := rows.Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.FruitName, &d.PublicationTitle, &d.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.URL = descURL(d.PublicationID, d.ID)
|
||||||
|
descs = append(descs, d)
|
||||||
|
}
|
||||||
|
return descs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) AddDescription(ctx context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error) {
|
||||||
|
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||||
|
return domain.PublicationDescription{}, err
|
||||||
|
}
|
||||||
|
var d domain.PublicationDescription
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO publication_descriptions (publication_id, fruit_id, pdf_data)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, publication_id, fruit_id, created_at`,
|
||||||
|
pubID, fruitID, data).
|
||||||
|
Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PublicationDescription{}, mapPgErrorToDesc(err)
|
||||||
|
}
|
||||||
|
d.URL = descURL(d.PublicationID, d.ID)
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) GetDescriptionData(ctx context.Context, pubID, descID int) ([]byte, error) {
|
||||||
|
var data []byte
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT pdf_data FROM publication_descriptions WHERE id=$1 AND publication_id=$2`,
|
||||||
|
descID, pubID).Scan(&data)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) DeleteDescription(ctx context.Context, pubID, descID int) error {
|
||||||
|
tag, err := r.pool.Exec(ctx,
|
||||||
|
`DELETE FROM publication_descriptions WHERE id=$1 AND publication_id=$2`, descID, pubID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error) {
|
||||||
|
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT pfi.id, pfi.publication_id, p.title, p.author, pfi.fruit_id, pfi.filename, pfi.image_type, pfi.created_at
|
||||||
|
FROM publication_fruit_images pfi
|
||||||
|
JOIN publications p ON p.id = pfi.publication_id
|
||||||
|
WHERE pfi.publication_id=$1 ORDER BY pfi.id`, pubID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
imgs := []domain.PublicationFruitImage{}
|
||||||
|
for rows.Next() {
|
||||||
|
var img domain.PublicationFruitImage
|
||||||
|
if err := rows.Scan(&img.ID, &img.PublicationID, &img.PublicationTitle, &img.PublicationAuthor,
|
||||||
|
&img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
|
||||||
|
imgs = append(imgs, img)
|
||||||
|
}
|
||||||
|
return imgs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error) {
|
||||||
|
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||||
|
return domain.PublicationFruitImage{}, err
|
||||||
|
}
|
||||||
|
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')
|
||||||
|
RETURNING id, publication_id, fruit_id, filename, image_type, created_at`,
|
||||||
|
pubID, fruitID, filename, data).
|
||||||
|
Scan(&img.ID, &img.PublicationID, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return domain.PublicationFruitImage{}, err
|
||||||
|
}
|
||||||
|
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
|
||||||
|
return img, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error) {
|
||||||
|
var data []byte
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT data FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`,
|
||||||
|
imgID, pubID).Scan(&data)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT pd.id, pd.publication_id, pd.fruit_id, f.name, p.title, pd.created_at
|
||||||
|
FROM publication_descriptions pd
|
||||||
|
JOIN fruits f ON f.id = pd.fruit_id
|
||||||
|
JOIN publications p ON p.id = pd.publication_id
|
||||||
|
WHERE pd.fruit_id=$1 ORDER BY pd.id`, fruitID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
descs := []domain.PublicationDescription{}
|
||||||
|
for rows.Next() {
|
||||||
|
var d domain.PublicationDescription
|
||||||
|
if err := rows.Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.FruitName, &d.PublicationTitle, &d.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.URL = descURL(d.PublicationID, d.ID)
|
||||||
|
descs = append(descs, d)
|
||||||
|
}
|
||||||
|
return descs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) GetFruitPublicationImages(ctx context.Context, fruitID int) ([]domain.PublicationFruitImage, error) {
|
||||||
|
rows, err := r.pool.Query(ctx,
|
||||||
|
`SELECT pfi.id, pfi.publication_id, p.title, p.author, pfi.fruit_id, pfi.filename, pfi.image_type, pfi.created_at
|
||||||
|
FROM publication_fruit_images pfi
|
||||||
|
JOIN publications p ON p.id = pfi.publication_id
|
||||||
|
WHERE pfi.fruit_id=$1 ORDER BY pfi.id`, fruitID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
imgs := []domain.PublicationFruitImage{}
|
||||||
|
for rows.Next() {
|
||||||
|
var img domain.PublicationFruitImage
|
||||||
|
if err := rows.Scan(&img.ID, &img.PublicationID, &img.PublicationTitle, &img.PublicationAuthor,
|
||||||
|
&img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
|
||||||
|
imgs = append(imgs, img)
|
||||||
|
}
|
||||||
|
return imgs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PublicationRepo) requirePublication(ctx context.Context, pubID int) error {
|
||||||
|
var exists bool
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM publications WHERE id=$1)`, pubID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return handler.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package repository_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"osdb/internal/domain"
|
||||||
|
"osdb/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicationRepo_Integration(t *testing.T) {
|
||||||
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("DATABASE_URL not set — skipping integration tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
repo := repository.NewPublicationRepo(pool)
|
||||||
|
fruitRepo := repository.NewFruitRepo(pool)
|
||||||
|
|
||||||
|
// Pre-cleanup stale test data from crashed prior runs
|
||||||
|
_, _ = pool.Exec(ctx, "DELETE FROM fruits WHERE osdb_number = $1", "TEST-PUB-001")
|
||||||
|
|
||||||
|
// Seed a fruit for linking
|
||||||
|
fruit, err := fruitRepo.Create(ctx, domain.FruitWriteDTO{
|
||||||
|
Name: "Testfrucht",
|
||||||
|
OSDBNumber: "TEST-PUB-001",
|
||||||
|
FruitType: "Apfelsorten",
|
||||||
|
Synonyms: []string{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed fruit: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { fruitRepo.Delete(ctx, fruit.ID) }) //nolint:errcheck
|
||||||
|
|
||||||
|
t.Run("create and get publication", func(t *testing.T) {
|
||||||
|
pub, err := repo.Create(ctx, domain.PublicationWriteDTO{
|
||||||
|
Title: "Pomologie Test",
|
||||||
|
OSDBPubID: "TEST-PUB-INTEG-001",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||||
|
|
||||||
|
if pub.ID == 0 {
|
||||||
|
t.Fatal("want non-zero ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := repo.Get(ctx, pub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Title != "Pomologie Test" {
|
||||||
|
t.Fatalf("want 'Pomologie Test' got %s", got.Title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate osdb_pub_id → ErrDuplicateOSDBPubID", func(t *testing.T) {
|
||||||
|
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Dup", OSDBPubID: "TEST-PUB-DUP-001"})
|
||||||
|
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||||
|
|
||||||
|
_, err := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Dup2", OSDBPubID: "TEST-PUB-DUP-001"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error for duplicate osdb_pub_id")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cover image round-trip", func(t *testing.T) {
|
||||||
|
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Img Test", OSDBPubID: "TEST-PUB-IMG-001"})
|
||||||
|
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||||
|
|
||||||
|
imgData := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic
|
||||||
|
if err := repo.SetImage(ctx, pub.ID, imgData); err != nil {
|
||||||
|
t.Fatalf("set image: %v", err)
|
||||||
|
}
|
||||||
|
data, err := repo.GetImageData(ctx, pub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get image data: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != string(imgData) {
|
||||||
|
t.Fatal("image data mismatch")
|
||||||
|
}
|
||||||
|
if err := repo.DeleteImage(ctx, pub.ID); err != nil {
|
||||||
|
t.Fatalf("delete image: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.GetImageData(ctx, pub.ID); err == nil {
|
||||||
|
t.Fatal("want not found after delete")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("link and unlink fruit", func(t *testing.T) {
|
||||||
|
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Link Test", OSDBPubID: "TEST-PUB-LINK-001"})
|
||||||
|
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||||
|
|
||||||
|
if err := repo.LinkFruit(ctx, pub.ID, fruit.ID); err != nil {
|
||||||
|
t.Fatalf("link fruit: %v", err)
|
||||||
|
}
|
||||||
|
fruits, err := repo.ListFruits(ctx, pub.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list fruits: %v", err)
|
||||||
|
}
|
||||||
|
if len(fruits) != 1 {
|
||||||
|
t.Fatalf("want 1 fruit got %d", len(fruits))
|
||||||
|
}
|
||||||
|
if err := repo.UnlinkFruit(ctx, pub.ID, fruit.ID); err != nil {
|
||||||
|
t.Fatalf("unlink fruit: %v", err)
|
||||||
|
}
|
||||||
|
fruits, _ = repo.ListFruits(ctx, pub.ID)
|
||||||
|
if len(fruits) != 0 {
|
||||||
|
t.Fatalf("want 0 fruits after unlink got %d", len(fruits))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("description upload and serve", func(t *testing.T) {
|
||||||
|
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Desc Test", OSDBPubID: "TEST-PUB-DESC-001"})
|
||||||
|
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||||
|
|
||||||
|
pdfData := []byte("%PDF-1.4 test content")
|
||||||
|
desc, err := repo.AddDescription(ctx, pub.ID, fruit.ID, pdfData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add description: %v", err)
|
||||||
|
}
|
||||||
|
data, err := repo.GetDescriptionData(ctx, pub.ID, desc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get desc data: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != string(pdfData) {
|
||||||
|
t.Fatal("pdf data mismatch")
|
||||||
|
}
|
||||||
|
if err := repo.DeleteDescription(ctx, pub.ID, desc.ID); err != nil {
|
||||||
|
t.Fatalf("delete desc: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fruit image upload and serve", func(t *testing.T) {
|
||||||
|
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "FruitImg Test", OSDBPubID: "TEST-PUB-FI-001"})
|
||||||
|
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||||
|
|
||||||
|
imgData := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||||
|
fname := "test.png"
|
||||||
|
img, err := repo.AddFruitImage(ctx, pub.ID, fruit.ID, &fname, imgData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add fruit image: %v", err)
|
||||||
|
}
|
||||||
|
if img.ImageType != "fruit" {
|
||||||
|
t.Fatalf("want image_type=fruit got %s", img.ImageType)
|
||||||
|
}
|
||||||
|
data, err := repo.GetFruitImageData(ctx, pub.ID, img.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get fruit image data: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != string(imgData) {
|
||||||
|
t.Fatal("image data mismatch")
|
||||||
|
}
|
||||||
|
if err := repo.DeleteFruitImage(ctx, pub.ID, img.ID); err != nil {
|
||||||
|
t.Fatalf("delete fruit image: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GetFruitDescriptions returns descriptions across publications", func(t *testing.T) {
|
||||||
|
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "CrossPub", OSDBPubID: "TEST-PUB-CROSS-001"})
|
||||||
|
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||||
|
|
||||||
|
_, _ = repo.AddDescription(ctx, pub.ID, fruit.ID, []byte("%PDF-1.4"))
|
||||||
|
descs, err := repo.GetFruitDescriptions(ctx, fruit.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get fruit descriptions: %v", err)
|
||||||
|
}
|
||||||
|
if len(descs) == 0 {
|
||||||
|
t.Fatal("want at least 1 description")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TABLE IF EXISTS publication_fruit_images;
|
||||||
|
DROP TABLE IF EXISTS publication_descriptions;
|
||||||
|
DROP TABLE IF EXISTS publication_fruits;
|
||||||
|
DROP TABLE IF EXISTS publications;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE publications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
author VARCHAR(255),
|
||||||
|
osdb_pub_id VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
image_data BYTEA,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE publication_fruits (
|
||||||
|
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
|
||||||
|
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (publication_id, fruit_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE publication_descriptions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
|
||||||
|
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
|
||||||
|
pdf_data BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (publication_id, fruit_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE publication_fruit_images (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
|
||||||
|
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
|
||||||
|
filename VARCHAR(255),
|
||||||
|
data BYTEA NOT NULL,
|
||||||
|
image_type VARCHAR(20) NOT NULL DEFAULT 'fruit',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import {
|
||||||
|
listPublications,
|
||||||
|
getPublication,
|
||||||
|
createPublication,
|
||||||
|
updatePublication,
|
||||||
|
deletePublication,
|
||||||
|
pubImageUrl,
|
||||||
|
pubDescriptionUrl,
|
||||||
|
pubFruitImageUrl,
|
||||||
|
} from './publications'
|
||||||
|
|
||||||
|
function mockFetch(body: unknown, status = 200) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: () => Promise.resolve(body),
|
||||||
|
text: () => Promise.resolve(String(body)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function stubFetch(body: unknown, status = 200) {
|
||||||
|
vi.stubGlobal('fetch', mockFetch(body, status))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('listPublications', () => {
|
||||||
|
it('returns array of publications', async () => {
|
||||||
|
stubFetch([{ id: 1, title: 'Pomologie', osdb_pub_id: 'P001' }])
|
||||||
|
const pubs = await listPublications()
|
||||||
|
expect(pubs).toHaveLength(1)
|
||||||
|
expect(pubs[0].title).toBe('Pomologie')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getPublication', () => {
|
||||||
|
it('returns publication by id', async () => {
|
||||||
|
stubFetch({ id: 1, title: 'Pomologie', osdb_pub_id: 'P001' })
|
||||||
|
const pub = await getPublication(1)
|
||||||
|
expect(pub.id).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on 404', async () => {
|
||||||
|
stubFetch({ error: 'not found' }, 404)
|
||||||
|
await expect(getPublication(99)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createPublication', () => {
|
||||||
|
it('returns created publication', async () => {
|
||||||
|
stubFetch({ id: 2, title: 'New', osdb_pub_id: 'P002' }, 201)
|
||||||
|
const pub = await createPublication({ title: 'New', osdb_pub_id: 'P002' })
|
||||||
|
expect(pub.id).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws 409 on duplicate osdb_pub_id', async () => {
|
||||||
|
stubFetch({ error: 'osdb_pub_id already exists' }, 409)
|
||||||
|
await expect(createPublication({ title: 'New', osdb_pub_id: 'P001' })).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updatePublication', () => {
|
||||||
|
it('returns updated publication', async () => {
|
||||||
|
stubFetch({ id: 1, title: 'Updated', osdb_pub_id: 'P001' })
|
||||||
|
const pub = await updatePublication(1, { title: 'Updated', osdb_pub_id: 'P001' })
|
||||||
|
expect(pub.title).toBe('Updated')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deletePublication', () => {
|
||||||
|
it('resolves on 204', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }))
|
||||||
|
await expect(deletePublication(1)).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('URL helpers', () => {
|
||||||
|
it('pubImageUrl returns correct path', () => {
|
||||||
|
expect(pubImageUrl(3)).toBe('/api/v1/publications/3/image')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pubDescriptionUrl returns correct path', () => {
|
||||||
|
expect(pubDescriptionUrl(3, 7)).toBe('/api/v1/publications/3/descriptions/7')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pubFruitImageUrl returns correct path', () => {
|
||||||
|
expect(pubFruitImageUrl(3, 9)).toBe('/api/v1/publications/3/fruit-images/9')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
export interface Publication {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
author: string | null
|
||||||
|
osdb_pub_id: string
|
||||||
|
image_url: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicationWriteDTO {
|
||||||
|
title: string
|
||||||
|
author?: string | null
|
||||||
|
osdb_pub_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicationFruit {
|
||||||
|
fruit_id: number
|
||||||
|
name: string
|
||||||
|
osdb_number: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicationDescription {
|
||||||
|
id: number
|
||||||
|
publication_id: number
|
||||||
|
fruit_id: number
|
||||||
|
fruit_name: string
|
||||||
|
publication_title: string
|
||||||
|
url: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicationFruitImage {
|
||||||
|
id: number
|
||||||
|
publication_id: number
|
||||||
|
publication_title: string
|
||||||
|
publication_author: string | null
|
||||||
|
fruit_id: number
|
||||||
|
filename: string | null
|
||||||
|
image_type: string
|
||||||
|
url: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pubImageUrl(pubId: number): string {
|
||||||
|
return `/api/v1/publications/${pubId}/image`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pubDescriptionUrl(pubId: number, descId: number): string {
|
||||||
|
return `/api/v1/publications/${pubId}/descriptions/${descId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pubFruitImageUrl(pubId: number, imgId: number): string {
|
||||||
|
return `/api/v1/publications/${pubId}/fruit-images/${imgId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOk(res: Response): Promise<Response> {
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `${res.status}`
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
msg = body.error ?? body.errors?.join(', ') ?? msg
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const err = new Error(msg) as Error & { status: number }
|
||||||
|
err.status = res.status
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPublications(): Promise<Publication[]> {
|
||||||
|
const res = await fetch('/api/v1/publications')
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublication(id: number): Promise<Publication> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${id}`)
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPublication(dto: PublicationWriteDTO): Promise<Publication> {
|
||||||
|
const res = await fetch('/api/v1/publications', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
})
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePublication(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
})
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePublication(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE' })
|
||||||
|
await checkOk(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCoverImage(pubId: number, file: File): Promise<void> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('image', file)
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form })
|
||||||
|
await checkOk(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCoverImage(pubId: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE' })
|
||||||
|
await checkOk(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPubFruits(pubId: number): Promise<PublicationFruit[]> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/fruits`)
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/fruits`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fruit_id: fruitId }),
|
||||||
|
})
|
||||||
|
await checkOk(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlinkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE' })
|
||||||
|
await checkOk(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDescriptions(pubId: number): Promise<PublicationDescription[]> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`)
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadDescription(pubId: number, fruitId: number, file: File): Promise<PublicationDescription> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('pdf', file)
|
||||||
|
form.append('fruit_id', String(fruitId))
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form })
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDescription(pubId: number, descId: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE' })
|
||||||
|
await checkOk(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPubFruitImages(pubId: number): Promise<PublicationFruitImage[]> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`)
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPubFruitImage(pubId: number, fruitId: number, file: File): Promise<PublicationFruitImage> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('image', file)
|
||||||
|
form.append('fruit_id', String(fruitId))
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form })
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePubFruitImage(pubId: number, imgId: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE' })
|
||||||
|
await checkOk(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFruitDescriptions(fruitId: number): Promise<PublicationDescription[]> {
|
||||||
|
const res = await fetch(`/api/v1/fruits/${fruitId}/descriptions`)
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFruitPublicationImages(fruitId: number): Promise<PublicationFruitImage[]> {
|
||||||
|
const res = await fetch(`/api/v1/fruits/${fruitId}/publication-images`)
|
||||||
|
return (await checkOk(res)).json()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ src: string; alt?: string }>()
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<div class="relative max-h-screen max-w-screen-lg p-4">
|
||||||
|
<button
|
||||||
|
class="absolute right-2 top-2 rounded bg-white/90 px-2 py-1 text-sm font-bold shadow hover:bg-white"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<img :src="src" :alt="alt ?? 'Bild'" class="max-h-[85vh] max-w-full rounded shadow-lg object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,9 @@ import HelloWorld from '../views/HelloWorld.vue'
|
|||||||
import FruitList from '../views/FruitList.vue'
|
import FruitList from '../views/FruitList.vue'
|
||||||
import FruitCreate from '../views/FruitCreate.vue'
|
import FruitCreate from '../views/FruitCreate.vue'
|
||||||
import FruitDetail from '../views/FruitDetail.vue'
|
import FruitDetail from '../views/FruitDetail.vue'
|
||||||
|
import PublicationList from '../views/PublicationList.vue'
|
||||||
|
import PublicationCreate from '../views/PublicationCreate.vue'
|
||||||
|
import PublicationDetail from '../views/PublicationDetail.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -25,6 +28,20 @@ const router = createRouter({
|
|||||||
component: FruitDetail,
|
component: FruitDetail,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/publications',
|
||||||
|
component: PublicationList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// /publications/new must come before /:id
|
||||||
|
path: '/publications/new',
|
||||||
|
component: PublicationCreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/publications/:id',
|
||||||
|
component: PublicationDetail,
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
listPublications,
|
||||||
|
getPublication,
|
||||||
|
createPublication,
|
||||||
|
updatePublication,
|
||||||
|
deletePublication,
|
||||||
|
type Publication,
|
||||||
|
type PublicationWriteDTO,
|
||||||
|
} from '../api/publications'
|
||||||
|
|
||||||
|
export const usePublicationStore = defineStore('publication', () => {
|
||||||
|
const publications = ref<Publication[]>([])
|
||||||
|
const current = ref<Publication | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchPublications() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
publications.value = await listPublications()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'unknown error'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublication(id: number) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
current.value = await getPublication(id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'unknown error'
|
||||||
|
current.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(dto: PublicationWriteDTO): Promise<Publication> {
|
||||||
|
const pub = await createPublication(dto)
|
||||||
|
publications.value = [pub, ...publications.value]
|
||||||
|
return pub
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||||
|
const pub = await updatePublication(id, dto)
|
||||||
|
const idx = publications.value.findIndex((p) => p.id === id)
|
||||||
|
if (idx !== -1) publications.value[idx] = pub
|
||||||
|
if (current.value?.id === id) current.value = pub
|
||||||
|
return pub
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
await deletePublication(id)
|
||||||
|
publications.value = publications.value.filter((p) => p.id !== id)
|
||||||
|
if (current.value?.id === id) current.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { publications, current, loading, error, fetchPublications, fetchPublication, create, update, remove }
|
||||||
|
})
|
||||||
@@ -4,6 +4,8 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useFruitStore } from '../stores/fruitStore'
|
import { useFruitStore } from '../stores/fruitStore'
|
||||||
import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits'
|
import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits'
|
||||||
import type { Fruit } from '../api/fruits'
|
import type { Fruit } from '../api/fruits'
|
||||||
|
import { getFruitDescriptions, getFruitPublicationImages } from '../api/publications'
|
||||||
|
import type { PublicationDescription, PublicationFruitImage } from '../api/publications'
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>()
|
const props = defineProps<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -26,9 +28,19 @@ const imageTitle = ref('')
|
|||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const uploadError = ref<string | null>(null)
|
const uploadError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const pubDescriptions = ref<PublicationDescription[]>([])
|
||||||
|
const pubFruitImages = ref<PublicationFruitImage[]>([])
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.fetchFruit(Number(props.id))
|
await store.fetchFruit(Number(props.id))
|
||||||
if (store.current) startEdit(store.current)
|
if (store.current) startEdit(store.current)
|
||||||
|
const id = Number(props.id)
|
||||||
|
const [descs, imgs] = await Promise.all([
|
||||||
|
getFruitDescriptions(id),
|
||||||
|
getFruitPublicationImages(id),
|
||||||
|
])
|
||||||
|
pubDescriptions.value = descs
|
||||||
|
pubFruitImages.value = imgs
|
||||||
})
|
})
|
||||||
|
|
||||||
function startEdit(f: Fruit) {
|
function startEdit(f: Fruit) {
|
||||||
@@ -258,6 +270,35 @@ async function removeImage(imageId: number) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Publication descriptions -->
|
||||||
|
<div v-if="pubDescriptions.length > 0" class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-3">Beschreibungen in Publikationen</h2>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li v-for="d in pubDescriptions" :key="d.id">
|
||||||
|
<a
|
||||||
|
:href="d.url"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-600 hover:underline text-sm"
|
||||||
|
>
|
||||||
|
{{ d.publication_title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Publication fruit images -->
|
||||||
|
<div v-if="pubFruitImages.length > 0" class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-3">Bilder aus Publikationen</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<div v-for="img in pubFruitImages" :key="img.id" class="border rounded overflow-hidden">
|
||||||
|
<img :src="img.url" :alt="img.filename ?? 'Fruchtbild'" class="w-full h-32 object-cover" />
|
||||||
|
<div class="p-1 text-xs text-gray-500">
|
||||||
|
{{ img.publication_title }}{{ img.publication_author ? ' · ' + img.publication_author : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<RouterLink to="/fruits" class="text-green-700 hover:underline text-sm">← Zurück zur Liste</RouterLink>
|
<RouterLink to="/fruits" class="text-green-700 hover:underline text-sm">← Zurück zur Liste</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { usePublicationStore } from '../stores/publicationStore'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = usePublicationStore()
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const author = ref('')
|
||||||
|
const osdbPubId = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const serverError = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = null
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const pub = await store.create({
|
||||||
|
title: title.value,
|
||||||
|
author: author.value || null,
|
||||||
|
osdb_pub_id: osdbPubId.value,
|
||||||
|
})
|
||||||
|
router.push(`/publications/${pub.id}`)
|
||||||
|
} catch (e) {
|
||||||
|
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-lg p-6">
|
||||||
|
<h1 class="mb-4 text-2xl font-bold">Neue Publikation</h1>
|
||||||
|
|
||||||
|
<div v-if="serverError" class="mb-4 rounded bg-red-50 p-3 text-red-700">{{ serverError }}</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Titel *</label>
|
||||||
|
<input v-model="title" required class="w-full rounded border px-3 py-2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Autor</label>
|
||||||
|
<input v-model="author" class="w-full rounded border px-3 py-2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">OSDB Kürzel *</label>
|
||||||
|
<input v-model="osdbPubId" required class="w-full rounded border px-3 py-2 font-mono" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="saving"
|
||||||
|
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Speichern…' : 'Anlegen' }}
|
||||||
|
</button>
|
||||||
|
<router-link to="/publications" class="rounded border px-4 py-2 hover:bg-gray-50">
|
||||||
|
Abbrechen
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { usePublicationStore } from '../stores/publicationStore'
|
||||||
|
import {
|
||||||
|
uploadCoverImage,
|
||||||
|
deleteCoverImage,
|
||||||
|
listPubFruits,
|
||||||
|
linkFruit,
|
||||||
|
unlinkFruit,
|
||||||
|
listDescriptions,
|
||||||
|
uploadDescription,
|
||||||
|
deleteDescription,
|
||||||
|
listPubFruitImages,
|
||||||
|
uploadPubFruitImage,
|
||||||
|
deletePubFruitImage,
|
||||||
|
type PublicationFruit,
|
||||||
|
type PublicationDescription,
|
||||||
|
type PublicationFruitImage,
|
||||||
|
} from '../api/publications'
|
||||||
|
import ImageOverlayDrawer from '../components/ImageOverlayDrawer.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = usePublicationStore()
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const editing = ref(false)
|
||||||
|
const editTitle = ref('')
|
||||||
|
const editAuthor = ref('')
|
||||||
|
const editOsdbPubId = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const serverError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Cover image
|
||||||
|
const coverFile = ref<File | null>(null)
|
||||||
|
const uploadingCover = ref(false)
|
||||||
|
const coverError = ref<string | null>(null)
|
||||||
|
const overlayOpen = ref(false)
|
||||||
|
|
||||||
|
// Linked fruits
|
||||||
|
const fruits = ref<PublicationFruit[]>([])
|
||||||
|
const linkFruitId = ref('')
|
||||||
|
const linkingFruit = ref(false)
|
||||||
|
|
||||||
|
// Descriptions
|
||||||
|
const descriptions = ref<PublicationDescription[]>([])
|
||||||
|
const descFile = ref<File | null>(null)
|
||||||
|
const descFruitId = ref('')
|
||||||
|
const uploadingDesc = ref(false)
|
||||||
|
const descError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Fruit images
|
||||||
|
const fruitImages = ref<PublicationFruitImage[]>([])
|
||||||
|
const fruitImgFile = ref<File | null>(null)
|
||||||
|
const fruitImgFruitId = ref('')
|
||||||
|
const uploadingFruitImg = ref(false)
|
||||||
|
const fruitImgError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const pubId = () => Number(props.id)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.fetchPublication(pubId())
|
||||||
|
if (store.current) {
|
||||||
|
editTitle.value = store.current.title
|
||||||
|
editAuthor.value = store.current.author ?? ''
|
||||||
|
editOsdbPubId.value = store.current.osdb_pub_id
|
||||||
|
}
|
||||||
|
await loadSubResources()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSubResources() {
|
||||||
|
const [f, d, fi] = await Promise.all([
|
||||||
|
listPubFruits(pubId()),
|
||||||
|
listDescriptions(pubId()),
|
||||||
|
listPubFruitImages(pubId()),
|
||||||
|
])
|
||||||
|
fruits.value = f
|
||||||
|
descriptions.value = d
|
||||||
|
fruitImages.value = fi
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
serverError.value = null
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await store.update(pubId(), {
|
||||||
|
title: editTitle.value,
|
||||||
|
author: editAuthor.value || null,
|
||||||
|
osdb_pub_id: editOsdbPubId.value,
|
||||||
|
})
|
||||||
|
editTitle.value = updated.title
|
||||||
|
editAuthor.value = updated.author ?? ''
|
||||||
|
editing.value = false
|
||||||
|
} catch (e) {
|
||||||
|
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!confirm('Publikation wirklich löschen?')) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await store.remove(pubId())
|
||||||
|
router.push('/publications')
|
||||||
|
} catch (e) {
|
||||||
|
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadCover() {
|
||||||
|
if (!coverFile.value) return
|
||||||
|
coverError.value = null
|
||||||
|
uploadingCover.value = true
|
||||||
|
try {
|
||||||
|
await uploadCoverImage(pubId(), coverFile.value)
|
||||||
|
coverFile.value = null
|
||||||
|
await store.fetchPublication(pubId())
|
||||||
|
} catch (e) {
|
||||||
|
coverError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
|
||||||
|
} finally {
|
||||||
|
uploadingCover.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCover() {
|
||||||
|
if (!confirm('Titelbild löschen?')) return
|
||||||
|
try {
|
||||||
|
await deleteCoverImage(pubId())
|
||||||
|
await store.fetchPublication(pubId())
|
||||||
|
} catch (e) {
|
||||||
|
coverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLinkFruit() {
|
||||||
|
const id = Number(linkFruitId.value)
|
||||||
|
if (!id) return
|
||||||
|
linkingFruit.value = true
|
||||||
|
try {
|
||||||
|
await linkFruit(pubId(), id)
|
||||||
|
linkFruitId.value = ''
|
||||||
|
fruits.value = await listPubFruits(pubId())
|
||||||
|
} catch {
|
||||||
|
// ignore – fruit may not exist
|
||||||
|
} finally {
|
||||||
|
linkingFruit.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnlinkFruit(fruitId: number) {
|
||||||
|
await unlinkFruit(pubId(), fruitId)
|
||||||
|
fruits.value = fruits.value.filter((f) => f.fruit_id !== fruitId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadDesc() {
|
||||||
|
if (!descFile.value || !descFruitId.value) return
|
||||||
|
descError.value = null
|
||||||
|
uploadingDesc.value = true
|
||||||
|
try {
|
||||||
|
const d = await uploadDescription(pubId(), Number(descFruitId.value), descFile.value)
|
||||||
|
descriptions.value.push(d)
|
||||||
|
descFile.value = null
|
||||||
|
descFruitId.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
descError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
|
||||||
|
} finally {
|
||||||
|
uploadingDesc.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDesc(descId: number) {
|
||||||
|
await deleteDescription(pubId(), descId)
|
||||||
|
descriptions.value = descriptions.value.filter((d) => d.id !== descId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFruitImg() {
|
||||||
|
if (!fruitImgFile.value || !fruitImgFruitId.value) return
|
||||||
|
fruitImgError.value = null
|
||||||
|
uploadingFruitImg.value = true
|
||||||
|
try {
|
||||||
|
const img = await uploadPubFruitImage(pubId(), Number(fruitImgFruitId.value), fruitImgFile.value)
|
||||||
|
fruitImages.value.push(img)
|
||||||
|
fruitImgFile.value = null
|
||||||
|
fruitImgFruitId.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
fruitImgError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
|
||||||
|
} finally {
|
||||||
|
uploadingFruitImg.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFruitImg(imgId: number) {
|
||||||
|
await deletePubFruitImage(pubId(), imgId)
|
||||||
|
fruitImages.value = fruitImages.value.filter((i) => i.id !== imgId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-3xl p-6">
|
||||||
|
<div v-if="store.loading" class="text-gray-500">Lade…</div>
|
||||||
|
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
||||||
|
<template v-else-if="store.current">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{{ store.current.title }}</h1>
|
||||||
|
<p class="text-sm text-gray-500 font-mono">{{ store.current.osdb_pub_id }}</p>
|
||||||
|
<p v-if="store.current.author" class="text-gray-700">{{ store.current.author }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded border px-3 py-1 text-sm hover:bg-gray-50"
|
||||||
|
@click="editing = !editing"
|
||||||
|
>
|
||||||
|
{{ editing ? 'Abbrechen' : 'Bearbeiten' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="deleting"
|
||||||
|
class="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:opacity-50"
|
||||||
|
@click="remove"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit form -->
|
||||||
|
<div v-if="editing" class="mb-6 rounded border p-4">
|
||||||
|
<div v-if="serverError" class="mb-3 text-red-600">{{ serverError }}</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Titel *</label>
|
||||||
|
<input v-model="editTitle" class="w-full rounded border px-3 py-2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">Autor</label>
|
||||||
|
<input v-model="editAuthor" class="w-full rounded border px-3 py-2" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium">OSDB Kürzel *</label>
|
||||||
|
<input v-model="editOsdbPubId" class="w-full rounded border px-3 py-2 font-mono" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
:disabled="saving"
|
||||||
|
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Speichern…' : 'Speichern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cover image -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="mb-2 text-lg font-semibold">Titelbild</h2>
|
||||||
|
<div v-if="store.current.image_url" class="mb-3 flex items-start gap-4">
|
||||||
|
<img
|
||||||
|
:src="store.current.image_url"
|
||||||
|
alt="Titelbild"
|
||||||
|
class="h-32 w-24 cursor-pointer rounded border object-cover shadow hover:opacity-90"
|
||||||
|
@click="overlayOpen = true"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="rounded border px-2 py-1 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
@click="removeCover"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mb-3 text-sm text-gray-400">Kein Titelbild vorhanden.</div>
|
||||||
|
<div v-if="coverError" class="mb-2 text-red-600 text-sm">{{ coverError }}</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="text-sm"
|
||||||
|
@change="coverFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="!coverFile || uploadingCover"
|
||||||
|
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="uploadCover"
|
||||||
|
>
|
||||||
|
{{ uploadingCover ? 'Hochladen…' : 'Hochladen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Linked fruits -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="mb-2 text-lg font-semibold">Verknüpfte Früchte</h2>
|
||||||
|
<ul v-if="fruits.length" class="mb-3 space-y-1">
|
||||||
|
<li v-for="f in fruits" :key="f.fruit_id" class="flex items-center justify-between rounded border px-3 py-1 text-sm">
|
||||||
|
<span>{{ f.name }} <span class="text-gray-400 font-mono text-xs">({{ f.osdb_number }})</span></span>
|
||||||
|
<button class="text-red-600 hover:underline text-xs" @click="doUnlinkFruit(f.fruit_id)">
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="mb-3 text-sm text-gray-400">Keine Früchte verknüpft.</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="linkFruitId"
|
||||||
|
type="number"
|
||||||
|
placeholder="Frucht-ID"
|
||||||
|
class="w-32 rounded border px-3 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="!linkFruitId || linkingFruit"
|
||||||
|
class="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700 disabled:opacity-50"
|
||||||
|
@click="doLinkFruit"
|
||||||
|
>
|
||||||
|
Verknüpfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Descriptions (PDFs) -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="mb-2 text-lg font-semibold">Beschreibungen (PDF)</h2>
|
||||||
|
<ul v-if="descriptions.length" class="mb-3 space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="d in descriptions"
|
||||||
|
:key="d.id"
|
||||||
|
class="flex items-center justify-between rounded border px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<a :href="d.url" target="_blank" class="text-blue-600 hover:underline">
|
||||||
|
{{ d.fruit_name || `Frucht #${d.fruit_id}` }}
|
||||||
|
</a>
|
||||||
|
<button class="text-red-600 hover:underline text-xs" @click="deleteDesc(d.id)">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="mb-3 text-sm text-gray-400">Keine Beschreibungen vorhanden.</div>
|
||||||
|
<div v-if="descError" class="mb-2 text-red-600 text-sm">{{ descError }}</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="descFruitId"
|
||||||
|
type="number"
|
||||||
|
placeholder="Frucht-ID"
|
||||||
|
class="w-28 rounded border px-3 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
class="text-sm"
|
||||||
|
@change="descFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="!descFile || !descFruitId || uploadingDesc"
|
||||||
|
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="uploadDesc"
|
||||||
|
>
|
||||||
|
{{ uploadingDesc ? 'Hochladen…' : 'PDF hochladen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Fruit images -->
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="mb-2 text-lg font-semibold">Fruchtbilder</h2>
|
||||||
|
<div v-if="fruitImages.length" class="mb-3 flex flex-wrap gap-3">
|
||||||
|
<div
|
||||||
|
v-for="img in fruitImages"
|
||||||
|
:key="img.id"
|
||||||
|
class="flex flex-col items-center gap-1"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="img.url"
|
||||||
|
:alt="img.filename ?? 'Fruchtbild'"
|
||||||
|
class="h-24 w-24 rounded border object-cover"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-gray-500">Frucht #{{ img.fruit_id }}</span>
|
||||||
|
<button class="text-xs text-red-600 hover:underline" @click="deleteFruitImg(img.id)">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mb-3 text-sm text-gray-400">Keine Fruchtbilder vorhanden.</div>
|
||||||
|
<div v-if="fruitImgError" class="mb-2 text-red-600 text-sm">{{ fruitImgError }}</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="fruitImgFruitId"
|
||||||
|
type="number"
|
||||||
|
placeholder="Frucht-ID"
|
||||||
|
class="w-28 rounded border px-3 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="text-sm"
|
||||||
|
@change="fruitImgFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="!fruitImgFile || !fruitImgFruitId || uploadingFruitImg"
|
||||||
|
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
@click="uploadFruitImg"
|
||||||
|
>
|
||||||
|
{{ uploadingFruitImg ? 'Hochladen…' : 'Bild hochladen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Full-size cover image overlay -->
|
||||||
|
<ImageOverlayDrawer
|
||||||
|
v-if="overlayOpen && store.current?.image_url"
|
||||||
|
:src="store.current.image_url"
|
||||||
|
alt="Titelbild (Vollansicht)"
|
||||||
|
@close="overlayOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { usePublicationStore } from '../stores/publicationStore'
|
||||||
|
|
||||||
|
const store = usePublicationStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await store.fetchPublications()
|
||||||
|
} catch {
|
||||||
|
// error already set in store
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-4xl p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Publikationen</h1>
|
||||||
|
<router-link
|
||||||
|
to="/publications/new"
|
||||||
|
class="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Neue Publikation
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.loading" class="text-gray-500">Lade…</div>
|
||||||
|
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
||||||
|
<div v-else-if="store.publications.length === 0" class="text-gray-500">
|
||||||
|
Keine Publikationen vorhanden.
|
||||||
|
</div>
|
||||||
|
<table v-else class="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-100 text-left">
|
||||||
|
<th class="border px-3 py-2">Titel</th>
|
||||||
|
<th class="border px-3 py-2">Autor</th>
|
||||||
|
<th class="border px-3 py-2">OSDB Kürzel</th>
|
||||||
|
<th class="border px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="pub in store.publications" :key="pub.id" class="hover:bg-gray-50">
|
||||||
|
<td class="border px-3 py-2">{{ pub.title }}</td>
|
||||||
|
<td class="border px-3 py-2">{{ pub.author ?? '—' }}</td>
|
||||||
|
<td class="border px-3 py-2 font-mono text-xs">{{ pub.osdb_pub_id }}</td>
|
||||||
|
<td class="border px-3 py-2">
|
||||||
|
<router-link :to="`/publications/${pub.id}`" class="text-blue-600 hover:underline">
|
||||||
|
Details
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user