diff --git a/docker-compose.yml b/docker-compose.yml index 07e73c6..2ff474a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: postgres: - image: postgres + image: postgres:16 volumes: - pg-data:/var/lib/postgresql/data environment: diff --git a/internal/controller/http/opds/opds.go b/internal/controller/http/opds/opds.go index de56937..f0c83df 100644 --- a/internal/controller/http/opds/opds.go +++ b/internal/controller/http/opds/opds.go @@ -3,6 +3,7 @@ package opds import ( "encoding/xml" "fmt" + "net/url" "time" "github.com/vanadium23/kompanion/internal/entity" @@ -120,7 +121,15 @@ func truncateText(text string, maxLen int) string { return text[:maxLen-3] + "..." } -func formNavLinks(baseURL string, books library.PaginatedBookList) []Link { +// formNavLinks creates navigation links for OPDS pagination. +// If searchQuery is not empty, it is included in the pagination links. +func formNavLinks(baseURL string, searchQuery string, books library.PaginatedBookList) []Link { + // Build query string prefix + queryPrefix := "?" + if searchQuery != "" { + queryPrefix = fmt.Sprintf("?search=%s&", url.QueryEscape(searchQuery)) + } + links := []Link{ { Href: baseURL, @@ -128,21 +137,21 @@ func formNavLinks(baseURL string, books library.PaginatedBookList) []Link { Rel: "start", }, { - Href: fmt.Sprintf("%s?page=%d", baseURL, books.Last()), + Href: fmt.Sprintf("%spage=%d", queryPrefix, books.Last()), Type: DirMime, Rel: "last", }, } if books.HasNext() { links = append(links, Link{ - Href: fmt.Sprintf("%s?page=%d", baseURL, books.Next()), + Href: fmt.Sprintf("%spage=%d", queryPrefix, books.Next()), Type: DirMime, Rel: "next", }) } if books.HasPrev() { links = append(links, Link{ - Href: fmt.Sprintf("%s?page=%d", baseURL, books.Prev()), + Href: fmt.Sprintf("%spage=%d", queryPrefix, books.Prev()), Type: DirMime, Rel: "prev", }) diff --git a/internal/controller/http/opds/router.go b/internal/controller/http/opds/router.go index 3d1d3a2..44b0a09 100644 --- a/internal/controller/http/opds/router.go +++ b/internal/controller/http/opds/router.go @@ -1,12 +1,14 @@ package opds import ( + "fmt" "net/http" "strconv" "time" "github.com/gin-gonic/gin" "github.com/vanadium23/kompanion/internal/auth" + "github.com/vanadium23/kompanion/internal/entity" "github.com/vanadium23/kompanion/internal/library" "github.com/vanadium23/kompanion/internal/sync" "github.com/vanadium23/kompanion/pkg/logger" @@ -60,16 +62,36 @@ func (r *OPDSRouter) listNewest(c *gin.Context) { if err != nil { page = 1 } - books, err := r.books.ListBooks(c.Request.Context(), "created_at", "desc", page, 10) + + // Parse search query parameter + searchQuery := c.Query("search") + + query := entity.SearchQuery{ + Search: searchQuery, + SortBy: "created_at", + SortOrder: "desc", + Page: page, + Limit: 10, + } + books, err := r.books.ListBooks(c.Request.Context(), query) if err != nil { r.logger.Error("failed to list newest books", err) c.JSON(http.StatusInternalServerError, gin.H{"message": "Internal server error", "code": 1001}) return } baseUrl := "/opds/newest/" + + // Build self URL including search query if present + selfUrl := baseUrl + feedTitle := "KOmpanion library" + if searchQuery != "" { + selfUrl = fmt.Sprintf("%s?search=%s", baseUrl, searchQuery) + feedTitle = fmt.Sprintf("KOmpanion library - Search: %s", searchQuery) + } + entries := translateBooksToEntries(books.Books) - navLinks := formNavLinks(baseUrl, books) - feed := BuildFeed("urn:kompanion:newest", "KOmpanion library", baseUrl, entries, navLinks) + navLinks := formNavLinks(baseUrl, searchQuery, books) + feed := BuildFeed("urn:kompanion:newest", feedTitle, selfUrl, entries, navLinks) c.XML(http.StatusOK, feed) } diff --git a/internal/controller/http/v1/books.go b/internal/controller/http/v1/books.go new file mode 100644 index 0000000..6d852d7 --- /dev/null +++ b/internal/controller/http/v1/books.go @@ -0,0 +1,136 @@ +package v1 + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/vanadium23/kompanion/internal/auth" + "github.com/vanadium23/kompanion/internal/entity" + "github.com/vanadium23/kompanion/internal/library" + "github.com/vanadium23/kompanion/pkg/logger" +) + +type booksRoutes struct { + shelf library.Shelf + l logger.Interface +} + +func newBooksRoutes(handler *gin.RouterGroup, shelf library.Shelf, a auth.AuthInterface, l logger.Interface) { + r := &booksRoutes{shelf: shelf, l: l} + + h := handler.Group("/books") + h.Use(authDeviceMiddleware(a, l)) + { + h.GET("", r.listBooks) + } +} + +// BookResponse represents a single book in the API response. +type BookResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + Description string `json:"description,omitempty"` + Publisher string `json:"publisher,omitempty"` + Year int `json:"year,omitempty"` + Series string `json:"series,omitempty"` + SeriesIndex *string `json:"series_index,omitempty"` + ISBN string `json:"isbn,omitempty"` + Format string `json:"format"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// BooksListResponse represents the paginated books list API response. +type BooksListResponse struct { + Books []BookResponse `json:"books"` + TotalPages int `json:"total_pages"` + CurrentPage int `json:"current_page"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +// listBooks handles GET /api/v1/books +// Query parameters: +// - search: search string (optional) +// - sort: field to sort by (title, author, series, created_at) +// - order: asc or desc (default: asc) +// - page: page number (default: 1) +// - limit: items per page (default: 50) +func (r *booksRoutes) listBooks(c *gin.Context) { + // Parse pagination parameters + page := 1 + if pageStr := c.Query("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + limit := 50 + if limitStr := c.Query("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + // Parse search query parameters + searchQuery := c.Query("search") + sortBy := c.Query("sort") + sortOrder := c.Query("order") + + // Default sort order + if sortOrder == "" { + sortOrder = "asc" + } + + query := entity.SearchQuery{ + Search: searchQuery, + SortBy: sortBy, + SortOrder: sortOrder, + Page: page, + Limit: limit, + } + + books, err := r.shelf.ListBooks(c.Request.Context(), query) + if err != nil { + r.l.Error(err, "failed to list books") + errorResponse(c, http.StatusInternalServerError, "failed to list books") + return + } + + // Convert to response format + bookResponses := make([]BookResponse, len(books.Books)) + for i, book := range books.Books { + var seriesIndex *string + if book.SeriesIndex != nil && book.SeriesIndex.Valid { + val := book.SeriesIndex.Decimal.String() + seriesIndex = &val + } + + bookResponses[i] = BookResponse{ + ID: book.ID, + Title: book.Title, + Author: book.Author, + Description: book.Description, + Publisher: book.Publisher, + Year: book.Year, + Series: book.Series, + SeriesIndex: seriesIndex, + ISBN: book.ISBN, + Format: book.Format, + CreatedAt: book.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: book.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + response := BooksListResponse{ + Books: bookResponses, + TotalPages: books.TotalPages(), + CurrentPage: page, + HasNext: books.HasNext(), + HasPrev: books.HasPrev(), + } + + c.JSON(http.StatusOK, response) +} diff --git a/internal/controller/http/v1/router.go b/internal/controller/http/v1/router.go index 02ea227..d8f20cb 100644 --- a/internal/controller/http/v1/router.go +++ b/internal/controller/http/v1/router.go @@ -31,4 +31,8 @@ func NewRouter(handler *gin.Engine, l logger.Interface, a auth.AuthInterface, p syncRoutes := handler.Group("/syncs") syncRoutes.Use(authDeviceMiddleware(a, l)) newSyncRoutes(syncRoutes, p, l) + + // API v1 routes + apiV1 := handler.Group("/api/v1") + newBooksRoutes(apiV1, shelf, a, l) } diff --git a/internal/controller/http/web/books.go b/internal/controller/http/web/books.go index e9aae9a..914f969 100644 --- a/internal/controller/http/web/books.go +++ b/internal/controller/http/web/books.go @@ -2,6 +2,7 @@ package web import ( "fmt" + "net/url" "os" "strconv" @@ -32,15 +33,37 @@ func newBooksRoutes(handler *gin.RouterGroup, shelf library.Shelf, stats stats.R } func (r *booksRoutes) listBooks(c *gin.Context) { + // Parse pagination parameters page := 1 - perPage := 12 // Show 12 books per page for grid layout if pageStr := c.Query("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p } } + perPage := 12 // Show 12 books per page for grid layout + + // Parse search query parameters + searchQuery := c.Query("search") + sortBy := c.Query("sort") + sortOrder := c.Query("order") + + // Default sort to created_at for web UI (newest first) + if sortBy == "" { + sortBy = "created_at" + } + if sortOrder == "" { + sortOrder = "desc" + } - books, err := r.shelf.ListBooks(c.Request.Context(), "created_at", "desc", page, perPage) + query := entity.SearchQuery{ + Search: searchQuery, + SortBy: sortBy, + SortOrder: sortOrder, + Page: page, + Limit: perPage, + } + + books, err := r.shelf.ListBooks(c.Request.Context(), query) if err != nil { c.HTML(500, "error", passStandartContext(c, gin.H{"error": err.Error()})) return @@ -64,8 +87,24 @@ func (r *booksRoutes) listBooks(c *gin.Context) { } } + // Build query string for pagination links (preserves search/sort/order) + queryParams := "" + if searchQuery != "" { + queryParams += "&search=" + url.QueryEscape(searchQuery) + } + if sortBy != "" { + queryParams += "&sort=" + url.QueryEscape(sortBy) + } + if sortOrder != "" { + queryParams += "&order=" + url.QueryEscape(sortOrder) + } + c.HTML(200, "books", passStandartContext(c, gin.H{ - "books": booksWithProgress, + "books": booksWithProgress, + "searchQuery": searchQuery, + "sortBy": sortBy, + "sortOrder": sortOrder, + "queryParams": queryParams, "pagination": gin.H{ "currentPage": page, "perPage": perPage, diff --git a/internal/entity/book.go b/internal/entity/book.go index dc40928..e980da8 100644 --- a/internal/entity/book.go +++ b/internal/entity/book.go @@ -10,6 +10,15 @@ import ( var ErrBookAlreadyExists = errors.New("Book already exists") +// SearchQuery represents search and filter parameters for book queries. +type SearchQuery struct { + Search string // search string for text matching + SortBy string // field to sort by (title, author, series, created_at) + SortOrder string // asc or desc + Page int // page number (1-indexed) + Limit int // items per page +} + // Book represents a book entity in the database. type Book struct { ID string // unique identifier for the book diff --git a/internal/library/book_postgres.go b/internal/library/book_postgres.go index 4d7f7a4..ea0cf21 100644 --- a/internal/library/book_postgres.go +++ b/internal/library/book_postgres.go @@ -119,7 +119,12 @@ func (bdr *BookDatabaseRepo) List(ctx context.Context, var book entity.Book var seriesIndex decimal.NullDecimal var summary sql.NullString - err = rows.Scan(&book.ID, &book.Title, &book.Author, &book.Publisher, &book.Year, &book.CreatedAt, &book.UpdatedAt, &book.ISBN, &book.FilePath, &book.DocumentID, &book.CoverPath, &book.Series, &seriesIndex, &summary) + var author sql.NullString + var publisher sql.NullString + var isbn sql.NullString + var coverPath sql.NullString + var series sql.NullString + err = rows.Scan(&book.ID, &book.Title, &author, &publisher, &book.Year, &book.CreatedAt, &book.UpdatedAt, &isbn, &book.FilePath, &book.DocumentID, &coverPath, &series, &seriesIndex, &summary) if err != nil { return nil, fmt.Errorf("BookDatabaseRepo - List - rows.Scan: %w", err) } @@ -129,6 +134,21 @@ func (bdr *BookDatabaseRepo) List(ctx context.Context, if summary.Valid { book.Description = summary.String } + if author.Valid { + book.Author = author.String + } + if publisher.Valid { + book.Publisher = publisher.String + } + if isbn.Valid { + book.ISBN = isbn.String + } + if coverPath.Valid { + book.CoverPath = coverPath.String + } + if series.Valid { + book.Series = series.String + } books = append(books, book) } @@ -148,7 +168,12 @@ func (bdr *BookDatabaseRepo) GetById(ctx context.Context, id string) (entity.Boo var book entity.Book var seriesIndex decimal.NullDecimal var summary sql.NullString - err := row.Scan(&book.ID, &book.Title, &book.Author, &book.Publisher, &book.Year, &book.CreatedAt, &book.UpdatedAt, &book.ISBN, &book.FilePath, &book.DocumentID, &book.CoverPath, &book.Series, &seriesIndex, &summary) + var author sql.NullString + var publisher sql.NullString + var isbn sql.NullString + var coverPath sql.NullString + var series sql.NullString + err := row.Scan(&book.ID, &book.Title, &author, &publisher, &book.Year, &book.CreatedAt, &book.UpdatedAt, &isbn, &book.FilePath, &book.DocumentID, &coverPath, &series, &seriesIndex, &summary) if err != nil { return entity.Book{}, fmt.Errorf("BookDatabaseRepo - Get - r.Pool.QueryRow: %w", err) } @@ -158,6 +183,21 @@ func (bdr *BookDatabaseRepo) GetById(ctx context.Context, id string) (entity.Boo if summary.Valid { book.Description = summary.String } + if author.Valid { + book.Author = author.String + } + if publisher.Valid { + book.Publisher = publisher.String + } + if isbn.Valid { + book.ISBN = isbn.String + } + if coverPath.Valid { + book.CoverPath = coverPath.String + } + if series.Valid { + book.Series = series.String + } return book, nil } @@ -175,7 +215,12 @@ func (bdr *BookDatabaseRepo) GetByFileHash(ctx context.Context, fileHash string) var book entity.Book var seriesIndex decimal.NullDecimal var summary sql.NullString - err := row.Scan(&book.ID, &book.Title, &book.Author, &book.Publisher, &book.Year, &book.CreatedAt, &book.UpdatedAt, &book.ISBN, &book.FilePath, &book.DocumentID, &book.CoverPath, &book.Series, &seriesIndex, &summary) + var author sql.NullString + var publisher sql.NullString + var isbn sql.NullString + var coverPath sql.NullString + var series sql.NullString + err := row.Scan(&book.ID, &book.Title, &author, &publisher, &book.Year, &book.CreatedAt, &book.UpdatedAt, &isbn, &book.FilePath, &book.DocumentID, &coverPath, &series, &seriesIndex, &summary) if err != nil { return entity.Book{}, fmt.Errorf("BookDatabaseRepo - GetByFileHash - r.Pool.QueryRow: %w", err) } @@ -185,6 +230,21 @@ func (bdr *BookDatabaseRepo) GetByFileHash(ctx context.Context, fileHash string) if summary.Valid { book.Description = summary.String } + if author.Valid { + book.Author = author.String + } + if publisher.Valid { + book.Publisher = publisher.String + } + if isbn.Valid { + book.ISBN = isbn.String + } + if coverPath.Valid { + book.CoverPath = coverPath.String + } + if series.Valid { + book.Series = series.String + } return book, nil } @@ -202,3 +262,164 @@ func (bdr *BookDatabaseRepo) Count(ctx context.Context) (int, error) { return count, nil } + +// Search -. search books with ILIKE and sorting +func (bdr *BookDatabaseRepo) Search(ctx context.Context, query entity.SearchQuery) ([]entity.Book, error) { + // Validate and set defaults for sort order + sortOrder := query.SortOrder + switch sortOrder { + case "asc", "desc": + default: + sortOrder = "asc" + } + + // Validate and set defaults for sort field + sortBy := query.SortBy + switch sortBy { + case "title", "author", "series", "created_at": + default: + sortBy = "title" + } + + // Set defaults for pagination + page := query.Page + if page <= 0 { + page = 1 + } + limit := query.Limit + if limit <= 0 || limit > 100 { + limit = 25 + } + + // Build WHERE clause for search + whereClause := "" + args := []interface{}{} + argIndex := 1 + + if query.Search != "" { + // Split search into words and create AND conditions + words := strings.Fields(query.Search) + conditions := make([]string, 0, len(words)) + + for _, word := range words { + likePattern := "%" + strings.ToLower(word) + "%" + conditions = append(conditions, fmt.Sprintf( + "(LOWER(title) LIKE $%d OR LOWER(author) LIKE $%d OR LOWER(COALESCE(series, '')) LIKE $%d OR LOWER(COALESCE(summary, '')) LIKE $%d)", + argIndex, argIndex, argIndex, argIndex, + )) + args = append(args, likePattern) + argIndex++ + } + + if len(conditions) > 0 { + whereClause = "WHERE " + strings.Join(conditions, " AND ") + } + } + + // Build ORDER BY clause with NULL series sorted last + orderByClause := "" + if sortBy == "series" { + // Sort by series, with NULL values last + if sortOrder == "asc" { + orderByClause = "ORDER BY series IS NULL, series ASC" + } else { + orderByClause = "ORDER BY series IS NULL, series DESC" + } + } else { + orderByClause = fmt.Sprintf("ORDER BY %s %s", sortBy, sortOrder) + } + + // Build complete query + baseQuery := ` + SELECT + id, title, author, publisher, year, created_at, updated_at, isbn, storage_file_path, koreader_partial_md5, storage_cover_path, series, series_index, summary + FROM library_book + ` + queryStr := fmt.Sprintf("%s %s %s LIMIT %d OFFSET %d", + baseQuery, whereClause, orderByClause, limit, (page-1)*limit) + + rows, err := bdr.Pool.Query(ctx, queryStr, args...) + if err != nil { + return nil, fmt.Errorf("BookDatabaseRepo - Search - r.Pool.Query: %w", err) + } + defer rows.Close() + + books := make([]entity.Book, 0) + for rows.Next() { + var book entity.Book + var seriesIndex decimal.NullDecimal + var summary sql.NullString + var author sql.NullString + var publisher sql.NullString + var isbn sql.NullString + var coverPath sql.NullString + var series sql.NullString + err = rows.Scan(&book.ID, &book.Title, &author, &publisher, &book.Year, &book.CreatedAt, &book.UpdatedAt, &isbn, &book.FilePath, &book.DocumentID, &coverPath, &series, &seriesIndex, &summary) + if err != nil { + return nil, fmt.Errorf("BookDatabaseRepo - Search - rows.Scan: %w", err) + } + if seriesIndex.Valid { + book.SeriesIndex = &seriesIndex + } + if summary.Valid { + book.Description = summary.String + } + if author.Valid { + book.Author = author.String + } + if publisher.Valid { + book.Publisher = publisher.String + } + if isbn.Valid { + book.ISBN = isbn.String + } + if coverPath.Valid { + book.CoverPath = coverPath.String + } + if series.Valid { + book.Series = series.String + } + books = append(books, book) + } + + return books, nil +} + +// SearchCount -. count search results +func (bdr *BookDatabaseRepo) SearchCount(ctx context.Context, query entity.SearchQuery) (int, error) { + // Build WHERE clause for search (same as Search) + whereClause := "" + args := []interface{}{} + argIndex := 1 + + if query.Search != "" { + // Split search into words and create AND conditions + words := strings.Fields(query.Search) + conditions := make([]string, 0, len(words)) + + for _, word := range words { + likePattern := "%" + strings.ToLower(word) + "%" + conditions = append(conditions, fmt.Sprintf( + "(LOWER(title) LIKE $%d OR LOWER(author) LIKE $%d OR LOWER(COALESCE(series, '')) LIKE $%d OR LOWER(COALESCE(summary, '')) LIKE $%d)", + argIndex, argIndex, argIndex, argIndex, + )) + args = append(args, likePattern) + argIndex++ + } + + if len(conditions) > 0 { + whereClause = "WHERE " + strings.Join(conditions, " AND ") + } + } + + queryStr := fmt.Sprintf("SELECT count(*) FROM library_book %s", whereClause) + + row := bdr.Pool.QueryRow(ctx, queryStr, args...) + var count int + err := row.Scan(&count) + if err != nil { + return 0, fmt.Errorf("BookDatabaseRepo - SearchCount - r.Pool.QueryRow: %w", err) + } + + return count, nil +} diff --git a/internal/library/interfaces.go b/internal/library/interfaces.go index 52838ab..9caaf02 100644 --- a/internal/library/interfaces.go +++ b/internal/library/interfaces.go @@ -11,10 +11,7 @@ type ( // Shelf -. Shelf interface { StoreBook(ctx context.Context, tempFile *os.File, uploadedFilename string) (entity.Book, error) - ListBooks(ctx context.Context, - sortBy, sortOrder string, - page, perPage int, - ) (PaginatedBookList, error) + ListBooks(ctx context.Context, query entity.SearchQuery) (PaginatedBookList, error) ViewBook(ctx context.Context, bookID string) (entity.Book, error) DownloadBook(ctx context.Context, bookID string) (entity.Book, *os.File, error) UpdateBookMetadata(ctx context.Context, bookID string, metadata entity.Book) (entity.Book, error) @@ -28,6 +25,8 @@ type ( sortBy, sortOrder string, page, perPage int, ) ([]entity.Book, error) + Search(ctx context.Context, query entity.SearchQuery) ([]entity.Book, error) + SearchCount(ctx context.Context, query entity.SearchQuery) (int, error) Count(ctx context.Context) (int, error) GetById(context.Context, string) (entity.Book, error) GetByFileHash(context.Context, string) (entity.Book, error) diff --git a/internal/library/shelf.go b/internal/library/shelf.go index 5065240..56afd5b 100644 --- a/internal/library/shelf.go +++ b/internal/library/shelf.go @@ -100,23 +100,80 @@ func (uc *BookShelf) StoreBook(ctx context.Context, tempFile *os.File, uploadedF return book, nil } -func (uc *BookShelf) ListBooks(ctx context.Context, - sortBy, sortOrder string, - page, perPage int) (PaginatedBookList, error) { - books, err := uc.repo.List(ctx, sortBy, sortOrder, page, perPage) - if err != nil { - return PaginatedBookList{}, fmt.Errorf("BookShelf - ListBooks - s.repo.List: %w", err) +// validSortFields contains the allowed sort fields for book queries. +var validSortFields = map[string]bool{ + "title": true, + "author": true, + "series": true, + "created_at": true, +} + +// validSortOrders contains the allowed sort orders. +var validSortOrders = map[string]bool{ + "asc": true, + "desc": true, +} + +// normalizeSearchQuery validates and sets defaults for a SearchQuery. +// Invalid sort fields default to "title", invalid sort orders default to "asc". +// Empty search returns all books (backward compatibility). +func normalizeSearchQuery(query entity.SearchQuery) entity.SearchQuery { + // Validate and set default for sort order + if !validSortOrders[query.SortOrder] { + query.SortOrder = "asc" } - totalCount, err := uc.repo.Count(ctx) - if err != nil { - return PaginatedBookList{}, fmt.Errorf("BookShelf - ListBooks - s.repo.Count: %w", err) + // Validate and set default for sort field + if !validSortFields[query.SortBy] { + query.SortBy = "title" + } + + // Set default for page + if query.Page <= 0 { + query.Page = 1 + } + + // Set default for limit + if query.Limit <= 0 || query.Limit > 100 { + query.Limit = 25 + } + + return query +} + +func (uc *BookShelf) ListBooks(ctx context.Context, query entity.SearchQuery) (PaginatedBookList, error) { + // Normalize and validate the query parameters + query = normalizeSearchQuery(query) + + var books []entity.Book + var totalCount int + var err error + + // If there's a search term, use search; otherwise use list all + if query.Search != "" { + books, err = uc.repo.Search(ctx, query) + if err != nil { + return PaginatedBookList{}, fmt.Errorf("BookShelf - ListBooks - s.repo.Search: %w", err) + } + totalCount, err = uc.repo.SearchCount(ctx, query) + if err != nil { + return PaginatedBookList{}, fmt.Errorf("BookShelf - ListBooks - s.repo.SearchCount: %w", err) + } + } else { + books, err = uc.repo.List(ctx, query.SortBy, query.SortOrder, query.Page, query.Limit) + if err != nil { + return PaginatedBookList{}, fmt.Errorf("BookShelf - ListBooks - s.repo.List: %w", err) + } + totalCount, err = uc.repo.Count(ctx) + if err != nil { + return PaginatedBookList{}, fmt.Errorf("BookShelf - ListBooks - s.repo.Count: %w", err) + } } pbl := NewPaginatedBookList( books, - perPage, - page, + query.Limit, + query.Page, totalCount, ) diff --git a/web/static/static.css b/web/static/static.css index 72b6a16..944e0f2 100644 --- a/web/static/static.css +++ b/web/static/static.css @@ -44,3 +44,69 @@ flex-grow: 1; margin-top: 0; } + +/* Search and Sort Controls */ +.search-controls { + margin: 1.5rem 0; + padding: 1rem; + border: var(--border-thickness) solid var(--text-color); +} + +.search-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.search-input-group { + display: flex; + gap: 0.5rem; +} + +.search-box { + flex-grow: 1; + padding: 0.5rem; + border: var(--border-thickness) solid var(--text-color); + background: var(--background-color); + color: var(--text-color); + font-family: inherit; +} + +.search-button { + padding: 0.5rem 1rem; + border: var(--border-thickness) solid var(--text-color); + background: var(--background-color); + color: var(--text-color); + cursor: pointer; + font-family: inherit; +} + +.search-button:hover { + background: var(--text-color); + color: var(--background-color); +} + +.sort-controls { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.sort-controls label { + font-weight: bold; +} + +.sort-controls select { + padding: 0.5rem; + border: var(--border-thickness) solid var(--text-color); + background: var(--background-color); + color: var(--text-color); + font-family: inherit; +} + +.no-books { + text-align: center; + padding: 2rem; + border: var(--border-thickness) solid var(--text-color); +} diff --git a/web/templates/books.html b/web/templates/books.html index 19079f2..d171b39 100644 --- a/web/templates/books.html +++ b/web/templates/books.html @@ -9,7 +9,31 @@ + + +
+
+
+ + +
+
+ + + +
+
+
+ {{ if .books }} {{ range .books }}
@@ -31,34 +55,40 @@

{{ end }} + {{ else }} +
+

No books found{{ with .searchQuery }} matching "{{ . }}"{{ end }}.

+ {{ if .searchQuery }}

Clear search

{{ end }} +
+ {{ end }}
-{{ with .pagination }} +{{ with $.pagination }}