Search existing books

This commit is contained in:
2025-10-14 00:29:53 +02:00
parent f72318b5bc
commit bb0ede6abd
13 changed files with 156 additions and 3 deletions

View File

@@ -8,6 +8,7 @@
"name": "personal-library-manager",
"version": "0.0.0",
"dependencies": {
"bootstrap-icons-vue": "^1.11.3",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-i18n": "^11.1.12",
@@ -1961,6 +1962,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/bootstrap-icons-vue": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/bootstrap-icons-vue/-/bootstrap-icons-vue-1.11.3.tgz",
"integrity": "sha512-Xba1GTDYon8KYSDTKiiAtiyfk4clhdKQYvCQPMkE58+F5loVwEmh0Wi+ECCfowNc9SGwpoSLpSkvg7rhgZBttw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",

View File

@@ -14,6 +14,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"bootstrap-icons-vue": "^1.11.3",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-i18n": "^11.1.12",

View File

@@ -0,0 +1,47 @@
<script setup>
const props = defineProps({
title: String,
author: String,
imagePath: String,
});
const imagePathOrDefault = (props.imagePath == "" || typeof props.imagePath === 'undefined') ? "../defaultbook.png" : props.imagePath;
</script>
<template>
<div class="box container has-background-dark">
<div class="media">
<div class="media-left">
<figure class="image mb-3">
<img v-bind:src="imagePathOrDefault" v-bind:alt="title">
</figure>
</div>
<div class="media-content">
<div class="is-size-4">{{title}}</div>
<div class="is-size-5 is-italic">{{author}}</div>
</div>
</div>
</div>
</template>
<style scoped>
img {
max-height:100px;
max-width:100px;
height:auto;
width:auto;
}
.box {
transition:ease-in-out 0.04s;
margin-bottom: 15px;
}
.box:hover {
transform: scale(1.01);
transition: ease-in-out 0.02s;
}
</style>

View File

@@ -14,7 +14,7 @@
<div class="navbar-item">
<div class="field has-addons">
<div class="control">
<input v-model="searchterm" class="input" type="text" />
<input v-model="searchterm" @keyup.enter="onSearchClick()" class="input" type="text" />
</div>
<div class="control">
<button @click="onSearchClick()" class="button">

View File

@@ -1,11 +1,31 @@
<script setup>
import BookListElement from './BookListElement.vue';
import { getSearchBooks } from './api.js'
import { onBeforeRouteUpdate } from 'vue-router'
const props = defineProps({
searchterm: String
});
let { data, error } = getSearchBooks(props.searchterm);
onBeforeRouteUpdate(async (to, from) => {
let res = getSearchBooks(to.params.searchterm);
data = res.data;
error = res.error;
})
</script>
<template>
<div> You are searching for {{ searchterm }} </div>
<div class="booksearch">
<div v-if="error">{{$t('searchbook.error', {error: error.message})}}</div>
<div class="booksearchlist" v-else-if="data && data.length > 0" v-for="book in data" :key="book.id">
<BookListElement v-bind="book" />
</div>
<div v-else-if="data === null">{{$t('searchbook.loading')}}</div>
<div v-else>{{$t('searchbook.noresult')}}</div>
</div>
</template>
<style scoped></style>

View File

@@ -28,6 +28,10 @@ export function getMyBooks() {
return useFetch(baseUrl + '/mybooks');
}
export function getSearchBooks(searchterm) {
return useFetch(baseUrl + '/search/' + searchterm);
}
export function postBook(book) {
return genericPostCall('/book', book.value)
}

View File

@@ -28,5 +28,10 @@
"bookbrowser": {
"error": "Error when loading books: {error}",
"loading": "Loading..."
},
"searchbook": {
"error": "Error when loading books: {error}",
"loading": "Loading...",
"noresult": "No results found."
}
}

View File

@@ -28,5 +28,10 @@
"bookbrowser": {
"error": "Erreur pendant le chargement des livres: {error}",
"loading": "Chargement..."
},
"searchbook": {
"error": "Erreur pendant le chargement des livres: {error}",
"loading": "Chargement...",
"noresult": "Aucun résultat trouvé."
}
}

View File

@@ -20,3 +20,8 @@ type bookUserGet struct {
Author string `json:"author" binding:"max=100"`
Rating int `json:"rating" binding:"min=0,max=10"`
}
type bookSearchGet struct {
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
}

View File

@@ -33,3 +33,10 @@ func (u userSignup) toUser() (model.User, error) {
user.Password = string(hashedPassword)
return user, nil
}
func fromBookDb(b *model.Book) bookSearchGet {
return bookSearchGet{
Title: b.Title,
Author: b.Author,
}
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"git.artlef.fr/PersonalLibraryManager/internal/appcontext"
"git.artlef.fr/PersonalLibraryManager/internal/i18nresource"
@@ -23,13 +24,24 @@ func GetMyBooksHanderl(ac appcontext.AppContext) {
return
}
ac.Db.Preload("Book").Where("user_id = ?", user.ID).Find(&userbooks)
var booksDto []bookUserGet
booksDto := make([]bookUserGet, 0)
for _, userbook := range userbooks {
booksDto = append(booksDto, fromUserBookDb(&userbook))
}
ac.C.JSON(http.StatusOK, booksDto)
}
func GetSearchBooksHandler(ac appcontext.AppContext) {
searchterm := ac.C.Param("searchterm")
var booksDb []model.Book
ac.Db.Where("LOWER(title) LIKE ?", "%"+strings.ToLower(searchterm)+"%").Find(&booksDb)
books := make([]bookSearchGet, 0)
for _, b := range booksDb {
books = append(books, fromBookDb(&b))
}
ac.C.JSON(http.StatusOK, books)
}
func PostBookHandler(ac appcontext.AppContext) {
var book bookPostCreate
err := ac.C.ShouldBindJSON(&book)

View File

@@ -26,6 +26,9 @@ func Setup(config *config.Config) *gin.Engine {
r.GET("/mybooks", func(c *gin.Context) {
api.GetMyBooksHanderl(appcontext.AppContext{C: c, Db: db, I18n: bundle})
})
r.GET("/search/:searchterm", func(c *gin.Context) {
api.GetSearchBooksHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle})
})
r.POST("/book", func(c *gin.Context) {
api.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle})
})

37
searchbook_test.go Normal file
View File

@@ -0,0 +1,37 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"testing"
"git.artlef.fr/PersonalLibraryManager/internal/testutils"
"github.com/stretchr/testify/assert"
)
type bookSearchGet struct {
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
}
func TestSearchBook(t *testing.T) {
router := testutils.TestSetup()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("GET", "/search/san", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var books []bookSearchGet
err := json.Unmarshal(w.Body.Bytes(), &books)
if err != nil {
log.Fatal(err)
}
assert.Equal(t, 200, w.Code)
assert.Equal(t, 2, len(books))
}