diff --git a/backend/apps/books/tests.py b/backend/apps/books/tests.py new file mode 100644 index 0000000..e8c39bb --- /dev/null +++ b/backend/apps/books/tests.py @@ -0,0 +1,255 @@ +"""Tests for the Book search & discovery endpoints.""" + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.books.models import Book, ReadingStatus + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def user(django_user_model): + return django_user_model.objects.create_user( + email="reader@example.com", + password="testpass123", + ) + + +@pytest.fixture +def auth_client(api_client, user): + api_client.force_authenticate(user=user) + return api_client + + +@pytest.fixture +def books(): + books_data = [ + Book.objects.create( + title="Dune", + author="Frank Herbert", + genre="Science Fiction", + reading_status=ReadingStatus.FINISHED, + total_pages=688, + description="A desert planet saga.", + ), + Book.objects.create( + title="Neuromancer", + author="William Gibson", + genre="Science Fiction", + reading_status=ReadingStatus.READING, + total_pages=271, + description="Cyberpunk classic.", + ), + Book.objects.create( + title="The Hobbit", + author="J.R.R. Tolkien", + genre="Fantasy", + reading_status=ReadingStatus.WANT_TO_READ, + total_pages=310, + description="A hobbit's adventure.", + ), + Book.objects.create( + title="1984", + author="George Orwell", + genre="Dystopian", + reading_status=ReadingStatus.FINISHED, + total_pages=328, + description="Big Brother is watching.", + ), + ] + return books_data + + +# --------------------------------------------------------------------------- +# Search tests +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBookSearch: + """Verify the search endpoint returns correct results.""" + + def test_search_by_title(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"q": "Dune"}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert "Dune" in titles + assert "Neuromancer" not in titles + + def test_search_by_author(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"q": "Tolkien"}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert "The Hobbit" in titles + + def test_search_by_genre(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"q": "Fantasy"}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert "The Hobbit" in titles + + def test_search_case_insensitive(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"q": "dune"}) + assert response.status_code == status.HTTP_200_OK + assert any(b["title"] == "Dune" for b in response.data["results"]) + + def test_search_partial_match(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"q": "Neu"}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert "Neuromancer" in titles + + def test_search_empty_query_returns_all(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"q": ""}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 4 + + def test_search_no_results(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"q": "zzzznotfound"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 0 + + +# --------------------------------------------------------------------------- +# Filter tests +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBookFilters: + """Verify filters for genre, author, and reading_status.""" + + def test_filter_by_genre(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"genre": "Science Fiction"}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert "Dune" in titles + assert "Neuromancer" in titles + assert "The Hobbit" not in titles + + def test_filter_by_author(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"author": "George Orwell"}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert "1984" in titles + assert "Dune" not in titles + + def test_filter_by_reading_status(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"reading_status": ReadingStatus.FINISHED}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert "Dune" in titles + assert "1984" in titles + assert "Neuromancer" not in titles + assert "The Hobbit" not in titles + + def test_filter_combined_with_search(self, auth_client, books): + """Search + filter should intersect results.""" + url = reverse("book-list") + response = auth_client.get(url, {"q": "Dune", "reading_status": ReadingStatus.FINISHED}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert "Dune" in titles + # 1984 matches reading_status but not search + assert "1984" not in titles + + +# --------------------------------------------------------------------------- +# Discovery endpoints +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBookDiscovery: + """Verify genre and author discovery endpoints.""" + + def test_genres_endpoint(self, auth_client, books): + url = reverse("book-genres") + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.data, list) + assert "Science Fiction" in response.data + assert "Fantasy" in response.data + assert "Dystopian" in response.data + # No duplicate genres + assert response.data.count("Science Fiction") == 1 + + def test_authors_endpoint(self, auth_client, books): + url = reverse("book-authors") + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert isinstance(response.data, list) + assert "Frank Herbert" in response.data + assert "J.R.R. Tolkien" in response.data + + def test_genres_requires_auth(self, api_client, books): + url = reverse("book-genres") + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_authors_requires_auth(self, api_client, books): + url = reverse("book-authors") + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +# --------------------------------------------------------------------------- +# Detail view +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestBookDetail: + """Verify the book detail endpoint.""" + + def test_retrieve_book(self, auth_client, books): + book = books[0] + url = reverse("book-detail", kwargs={"pk": book.pk}) + response = auth_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data["title"] == "Dune" + assert response.data["author"] == "Frank Herbert" + assert response.data["description"] == "A desert planet saga." + assert response.data["total_pages"] == 688 + + def test_retrieve_nonexistent_returns_404(self, auth_client, books): + url = reverse("book-detail", kwargs={"pk": 99999}) + response = auth_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_pagination(self, auth_client, books): + """List with small page size should paginate.""" + url = reverse("book-list") + response = auth_client.get(f"{url}?page_size=2") + assert response.status_code == status.HTTP_200_OK + assert "count" in response.data + assert "results" in response.data + assert response.data["count"] == 4 + + def test_ordering(self, auth_client, books): + url = reverse("book-list") + response = auth_client.get(url, {"ordering": "title"}) + assert response.status_code == status.HTTP_200_OK + titles = [b["title"] for b in response.data["results"]] + assert titles == sorted(titles) \ No newline at end of file diff --git a/backend/apps/books/views.py b/backend/apps/books/views.py index 4b24c06..86c0b51 100644 --- a/backend/apps/books/views.py +++ b/backend/apps/books/views.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import logging from typing import Any from django.db.models import QuerySet, Q @@ -9,13 +12,16 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response -from apps.books.models import Book, EBook, ReadingProgress, ReadingSettings +from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingSettings from apps.books.serializers import ( BookDetailSerializer, BookListSerializer, BookSerializer, - EBookDetailSerializer, EBookListSerializer, EBookUploadSerializer, + BookChapterSerializer, EBookContentSerializer, EBookDetailSerializer, + EBookListSerializer, EBookTocSerializer, EBookUploadSerializer, ReadingProgressSerializer, ReadingSettingsSerializer, ) +logger = logging.getLogger(__name__) + class BookViewSet(viewsets.ModelViewSet): queryset = Book.objects.all() @@ -63,8 +69,10 @@ class EBookViewSet(viewsets.ModelViewSet): def get_serializer_class(self): if self.action == "create": return EBookUploadSerializer - if self.action == "list": + if self.action in ("list",): return EBookListSerializer + if self.action in ("toc",): + return BookChapterSerializer return EBookDetailSerializer def get_queryset(self): @@ -82,6 +90,135 @@ class EBookViewSet(viewsets.ModelViewSet): serializer.save() return Response(serializer.data) + @action(detail=True, methods=["post"]) + def process(self, request: Request, pk: int | None = None) -> Response: + """Trigger e-book processing: metadata extraction, TOC building, page counting.""" + ebook = self.get_object() + if not ebook.file: + return Response({"error": "No file found for this e-book."}, status=status.HTTP_400_BAD_REQUEST) + + try: + from apps.books.services import process_ebook + + file_path = ebook.file.path + result = process_ebook(file_path, original_filename=ebook.filename()) + + # Update ebook with extracted data + ebook.format = result.get("format", ebook.format) + ebook.page_count = result.get("page_count", 0) + ebook.metadata_json = result.get("metadata", {}) + ebook.save(update_fields=["format", "page_count", "metadata_json", "updated_at"]) + + # Store chapters in DB + raw_toc: list[dict[str, Any]] = result.get("toc", []) + BookChapter.objects.filter(ebook=ebook).delete() + _store_chapters(ebook, raw_toc) + + return Response({ + "format": ebook.format, + "page_count": ebook.page_count, + "metadata": ebook.metadata_json, + "toc_count": len(raw_toc), + "status": "processed", + }) + except Exception as exc: + logger.exception("Failed to process ebook %s", ebook.id) + return Response({"error": f"Processing failed: {exc}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=["get"]) + def toc(self, request: Request, pk: int | None = None) -> Response: + """Return hierarchical table of contents.""" + ebook = self.get_object() + chapters = BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook") + serializer = BookChapterSerializer(chapters, many=True) + return Response({ + "chapters": serializer.data, + "format": ebook.format, + "page_count": ebook.page_count, + }) + + @action(detail=True, methods=["get"]) + def content(self, request: Request, pk: int | None = None) -> Response: + """Return paginated content for a given page number. + + Query params: + page (int): page/chapter index to fetch (1-indexed, default: 1) + """ + ebook = self.get_object() + page = max(1, int(request.query_params.get("page", 1))) + + chapters = list(BookChapter.objects.filter(ebook=ebook).order_by("index").select_related("ebook")) + total_pages = len(chapters) or ebook.page_count or 1 + + chapter: BookChapter | None = None + chapter_title = "" + content_html = "" + + if chapters and 0 <= page - 1 < len(chapters): + ch = chapters[page - 1] + chapter_title = ch.title + content_html = _fetch_chapter_content(ebook, ch) + + serializer = EBookContentSerializer(data={ + "page": page, + "total_pages": total_pages, + "content": content_html, + "chapter_title": chapter_title, + "format": ebook.format, + }) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + +def _store_chapters(ebook: EBook, toc: list[dict[str, Any]], parent_index: int = 0) -> None: + """Recursively store TOC entries as BookChapter records.""" + for idx, entry in enumerate(toc): + BookChapter.objects.create( + ebook=ebook, + title=entry.get("title", "Untitled"), + index=parent_index + idx, + href=entry.get("href", ""), + children=entry.get("children", []), + ) + children = entry.get("children", []) + if children: + _store_chapters(ebook, children, parent_index + idx + 1) + + +def _fetch_chapter_content(ebook: EBook, chapter: BookChapter) -> str: + """Fetch HTML content for a chapter from the e-book file.""" + if ebook.format == "epub": + return _fetch_epub_chapter_content(ebook.file.path, chapter) + return "" + + +def _fetch_epub_chapter_content(file_path: str, chapter: BookChapter) -> str: + """Extract HTML content of a specific EPUB chapter by href.""" + try: + from ebooklib import epub + from bs4 import BeautifulSoup + except ImportError: + return "" + + try: + book = epub.read_epub(file_path) + href = chapter.href or "" + # Find the item by href + for item in book.get_items(): + item_name = item.get_name() or "" + if href and (item_name.endswith(href) or href.endswith(item_name)): + content = item.get_content() + soup = BeautifulSoup(content, "html.parser") + # Clean up — remove body/html/head wrappers, keep inner content + body = soup.find("body") + if body: + return str(body) + return str(soup) + return "" + except Exception: + logger.exception("Failed to fetch EPUB chapter content for %s", chapter.href) + return "" + class ReadingSettingsViewSet(viewsets.GenericViewSet): permission_classes = [IsAuthenticated] diff --git a/docs/backend/search-discovery-spec.md b/docs/backend/search-discovery-spec.md new file mode 100644 index 0000000..eeff846 --- /dev/null +++ b/docs/backend/search-discovery-spec.md @@ -0,0 +1,98 @@ +# Book Search & Discovery — Spec + +## Overview +Enable users to search books within the library and discover new books via filters and a dedicated detail view. + +## Backend API Contracts + +### Book List & Search +`GET /api/books/` + +**Query Parameters:** +| Param | Type | Description | +|-------|------|-------------| +| `q` | string | Full-text search across title, author, genre | +| `genre` | string | Exact filter by genre | +| `author` | string | Exact filter by author | +| `reading_status` | string | Filter: `want_to_read`, `reading`, `finished`, `dnf` | +| `ordering` | string | `title`, `author`, `genre`, `created_at` (prefix `-` for desc) | +| `page` | int | Page number (default: 1) | + +**Response (paginated):** +```json +{ + "count": 42, + "next": "http://.../?page=2", + "previous": null, + "results": [ + { + "id": 1, + "title": "Dune", + "author": "Frank Herbert", + "genre": "Science Fiction", + "reading_status": "finished", + "reading_status_display": "Finished", + "cover_image": "https://..." + } + ] +} +``` + +### Book Detail +`GET /api/books/{id}/` + +**Response:** +```json +{ + "id": 1, + "title": "Dune", + "author": "Frank Herbert", + "genre": "Science Fiction", + "description": "...", + "reading_status": "finished", + "reading_status_display": "Finished", + "cover_image": "https://...", + "total_pages": 688, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-15T00:00:00Z" +} +``` + +### Genre / Author Discovery +`GET /api/books/genres/` → `["Fiction", "Science Fiction", ...]` + +`GET /api/books/authors/` → `["Frank Herbert", "Ursula K. Le Guin", ...]` + +## Frontend Components + +### LibraryPage (enhanced) +- **Search bar** at top: text input with debounced `onChange` → calls API with `q` param +- **Filter row**: genre dropdown, author dropdown, reading status dropdown + - Genre/Author dropdowns populated from `/api/books/genres/` and `/api/books/authors/` + - Reading status uses static enum values +- **Results grid**: card layout showing cover, title, author, reading status badge +- **Empty state**: "No books found" with clear message when results are empty +- **Loading state**: spinner/skeleton while fetching +- **Click card → navigate to** `/books/{id}` + +### BookDetailPage (new) +- Shows full book info: cover, title, author, genre, description, reading status, total pages +- Back button to return to library +- Clean, mobile-responsive layout + +## Routes (Frontend) +| Path | Component | Auth | +|------|-----------|------| +| `/` | LibraryPage | Protected | +| `/books/:id` | BookDetailPage | Protected | + +## Data Flow +1. User types in search bar → 300ms debounce → `GET /api/books/?q=...` +2. User selects filter → `GET /api/books/?genre=...&author=...&reading_status=...` +3. User clicks result → navigate to `/books/:id` +4. BookDetailPage → `GET /api/books/{id}/` + +## Mobile Optimizations +- Filters collapse into a toggleable panel on small screens +- Cards stack in single column on mobile +- Touch-friendly tap targets (min 44px) \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e55734d..8d33748 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider, useAuth } from "./context/AuthContext"; const LibraryPage = lazy(() => import("./pages/Library").then((m) => ({ default: m.LibraryPage }))); +const BookDetailPage = lazy(() => import("./pages/BookDetailPage").then((m) => ({ default: m.BookDetailPage }))); const ReaderPage = lazy(() => import("./pages/Reader").then((m) => ({ default: m.ReaderPage }))); const AddBookPage = lazy(() => import("./pages/AddBook").then((m) => ({ default: m.AddBookPage }))); const SettingsPage = lazy(() => import("./pages/Settings").then((m) => ({ default: m.SettingsPage }))); @@ -35,6 +36,7 @@ function AppRoutes() { : } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/books.ts b/frontend/src/api/books.ts index acc9e1d..5d65379 100644 --- a/frontend/src/api/books.ts +++ b/frontend/src/api/books.ts @@ -1,5 +1,5 @@ import api from "./client"; -import type { EBookDetail, EBookListItem, ReadingProgress, ReadingSettings } from "../types/book"; +import type { ContentResponse, EBookDetail, EBookListItem, ReadingProgress, ReadingSettings, TocResponse } from "../types/book"; export const booksApi = { async getEBooks(): Promise { @@ -34,6 +34,21 @@ export const booksApi = { await api.delete(`/books/ebooks/${id}/`); }, + async processEBook(id: number): Promise<{ status: string }> { + const { data } = await api.post<{ status: string }>(`/books/ebooks/${id}/process/`); + return data; + }, + + async getToc(id: number): Promise { + const { data } = await api.get(`/books/ebooks/${id}/toc/`); + return data; + }, + + async getContent(id: number, page: number): Promise { + const { data } = await api.get(`/books/ebooks/${id}/content/?page=${page}`); + return data; + }, + async getProgress(ebookId: number): Promise { const { data } = await api.get(`/books/ebooks/${ebookId}/progress/`); return data; diff --git a/frontend/src/pages/BookDetailPage.tsx b/frontend/src/pages/BookDetailPage.tsx new file mode 100644 index 0000000..489513b --- /dev/null +++ b/frontend/src/pages/BookDetailPage.tsx @@ -0,0 +1,159 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { booksApi } from "../api/books"; +import type { BookDetail } from "../types/book"; + +export function BookDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [book, setBook] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadBook = useCallback(async () => { + if (!id) return; + setLoading(true); + setError(null); + try { + const bookId = Number(id); + if (Number.isNaN(bookId)) { + setError("Invalid book ID"); + return; + } + const data = await booksApi.getBook(bookId); + setBook(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load book details"); + } finally { + setLoading(false); + } + }, [id]); + + useEffect(() => { + void loadBook(); + }, [loadBook]); + + const statusColors: Record = { + want_to_read: { bg: "#dbeafe", text: "#1d4ed8" }, + reading: { bg: "#dcfce7", text: "#16a34a" }, + finished: { bg: "#f3e8ff", text: "#9333ea" }, + dnf: { bg: "#fef3c7", text: "#b45309" }, + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !book) { + return ( +
+ +
+
😕
+

Book not found

+

{error || "The book you're looking for doesn't exist or has been removed."}

+ +
+
+ ); + } + + const sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" }; + + return ( +
+ {/* Back button */} + + + {/* Book Detail */} +
+ {/* Cover */} +
+
+ {book.cover_image + ? {book.title} + : 📖} +
+
+ + {/* Info */} +
+

+ {book.title} +

+ + {book.author && ( +

+ by {book.author} +

+ )} + +
+ + {book.reading_status_display} + + {book.genre && ( + + {book.genre} + + )} + {book.total_pages > 0 && ( + + {book.total_pages} pages + + )} +
+ + {book.description && ( +
+

Description

+

+ {book.description} +

+
+ )} + +
+

+ Added {new Date(book.created_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })} + {book.created_at !== book.updated_at && ` · Updated ${new Date(book.updated_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}`} +

+
+ + {/* Mobile-only: open in reader if it's an ebook, or just navigate back */} +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Library.tsx b/frontend/src/pages/Library.tsx index 848b012..cc923f6 100644 --- a/frontend/src/pages/Library.tsx +++ b/frontend/src/pages/Library.tsx @@ -1,61 +1,305 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { booksApi } from "../api/books"; -import type { EBookListItem } from "../types/book"; +import type { BookListItem, BookSearchParams } from "../types/book"; +import { READING_STATUS_OPTIONS } from "../types/book"; import { useAuth } from "../context/AuthContext"; +interface FilterState { + genre: string; + author: string; + reading_status: string; +} + +function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + return debounced; +} + export function LibraryPage() { - const [books, setBooks] = useState([]); + const navigate = useNavigate(); + const { logout } = useAuth(); + + const [books, setBooks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { logout } = useAuth(); - const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(""); + const [filters, setFilters] = useState({ genre: "", author: "", reading_status: "" }); + const [genres, setGenres] = useState([]); + const [authors, setAuthors] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [showFilters, setShowFilters] = useState(false); - const loadBooks = useCallback(async () => { + const debouncedSearch = useDebounce(searchQuery, 300); + const loadedRef = useRef(false); + + // Load filter options once + useEffect(() => { + if (loadedRef.current) return; + loadedRef.current = true; + void Promise.all([booksApi.getGenres(), booksApi.getAuthors()]).then( + ([genreList, authorList]) => { + setGenres(genreList); + setAuthors(authorList); + }, + () => { + // Filters degrade gracefully if discovery endpoints fail + }, + ); + }, []); + + const loadBooks = useCallback(async (params: BookSearchParams) => { setLoading(true); setError(null); try { - const data = await booksApi.getEBooks(); - setBooks(data); + const response = await booksApi.searchBooks(params); + setBooks(response.results); + setTotalCount(response.count); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load library"); + setError(err instanceof Error ? err.message : "Failed to load books"); + setBooks([]); + setTotalCount(0); } finally { setLoading(false); } }, []); - useEffect(() => { void loadBooks(); }, [loadBooks]); + // Reload when search or filters change + useEffect(() => { + const params: BookSearchParams = {}; + if (debouncedSearch) params.q = debouncedSearch; + if (filters.genre) params.genre = filters.genre; + if (filters.author) params.author = filters.author; + if (filters.reading_status) params.reading_status = filters.reading_status; + void loadBooks(params); + }, [debouncedSearch, filters, loadBooks]); + + const handleFilterChange = (key: keyof FilterState, value: string) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }; + + const clearAllFilters = () => { + setSearchQuery(""); + setFilters({ genre: "", author: "", reading_status: "" }); + }; + + const hasActiveFilters = !!searchQuery || !!filters.genre || !!filters.author || !!filters.reading_status; return ( -
-
-

My Library

+
+ {/* Header */} +
+
+

Library

+ {!loading &&

{totalCount} book{totalCount !== 1 ? "s" : ""}

} +
- - - - + + + +
- {error &&

{error}

} - {loading && books.length === 0 &&

Loading your library...

} - {!loading && !error && books.length === 0 &&

Your library is empty

Add a book to get started

} - -
- {books.map((book) => ( -
navigate(`/reader/${book.id}`)} style={{ background: "#fff", borderRadius: 12, overflow: "hidden", boxShadow: "0 2px 8px rgba(0,0,0,0.06)", cursor: "pointer" }}> -
- {book.cover_image ? {book.title} : 📖} -
-
-

{book.title}

-

{book.author || "Unknown Author"}

- {book.progress !== null &&
} -
+ {/* Search Bar */} +
+
+
+ setSearchQuery(e.target.value)} + style={{ + width: "100%", padding: "12px 16px 12px 44px", borderRadius: 10, + border: "1px solid #e5e7eb", fontSize: 15, background: "#fff", + outline: "none", boxSizing: "border-box", + }} + /> + 🔍
- ))} + +
+ + {/* Filters Panel */} + {showFilters && ( +
+
+ + +
+
+ + +
+
+ + +
+ {hasActiveFilters && ( + + )} +
+ )} + + {/* Error State */} + {error && ( +
+

{error}

+ +
+ )} + + {/* Loading State */} + {loading && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ )} + + {/* Empty State */} + {!loading && !error && books.length === 0 && ( +
+
{hasActiveFilters ? "🔍" : "📚"}
+

+ {hasActiveFilters ? "No books found" : "Your library is empty"} +

+

+ {hasActiveFilters + ? "Try adjusting your search query or filters to discover more books." + : "Add a book to get started building your collection."} +

+ {hasActiveFilters ? ( + + ) : ( + + )} +
+ )} + + {/* Results Grid */} + {!loading && books.length > 0 && ( +
+ {books.map((book) => { + const statusColors: Record = { + want_to_read: { bg: "#dbeafe", text: "#1d4ed8" }, + reading: { bg: "#dcfce7", text: "#16a34a" }, + finished: { bg: "#f3e8ff", text: "#9333ea" }, + dnf: { bg: "#fef3c7", text: "#b45309" }, + }; + const sc = statusColors[book.reading_status] ?? { bg: "#f3f4f6", text: "#6b7280" }; + + return ( +
navigate(`/books/${book.id}`)} + style={{ + background: "#fff", borderRadius: 12, overflow: "hidden", + boxShadow: "0 2px 8px rgba(0,0,0,0.06)", cursor: "pointer", + transition: "transform 0.15s, box-shadow 0.15s", + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.transform = "translateY(-2px)"; + (e.currentTarget as HTMLElement).style.boxShadow = "0 4px 16px rgba(0,0,0,0.1)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.transform = ""; + (e.currentTarget as HTMLElement).style.boxShadow = "0 2px 8px rgba(0,0,0,0.06)"; + }} + > +
+ {book.cover_image + ? {book.title} + : 📖} + + {book.reading_status_display} + +
+
+

+ {book.title} +

+

+ {book.author || "Unknown Author"} +

+ {book.genre && ( + + {book.genre} + + )} +
+
+ ); + })} +
+ )}
); } \ No newline at end of file diff --git a/frontend/src/types/book.ts b/frontend/src/types/book.ts index e120cae..b81a169 100644 --- a/frontend/src/types/book.ts +++ b/frontend/src/types/book.ts @@ -27,6 +27,8 @@ export interface EBookListItem { title: string; author: string; filename: string; + format: string; + page_count: number; cover_image: string | null; created_at: string; progress: number | null; @@ -38,6 +40,10 @@ export interface EBookDetail { author: string; filename: string; file_url: string; + format: string; + page_count: number; + file_size: number; + metadata_json: Record; cover_image: string | null; created_at: string; updated_at: string; @@ -53,4 +59,26 @@ export interface ReadingSettings { font_size: number; font_style: "sans-serif" | "serif" | "monospace"; background_color: string; +} + +export interface BookChapter { + id: number; + title: string; + index: number; + href: string; + children: BookChapter[]; +} + +export interface TocResponse { + chapters: BookChapter[]; + format: string; + page_count: number; +} + +export interface ContentResponse { + page: number; + total_pages: number; + content: string; + chapter_title: string; + format: string; } \ No newline at end of file