diff --git a/backend/apps/annotations/migrations/0004_alter_bookmark_content_and_more.py b/backend/apps/annotations/migrations/0004_alter_bookmark_content_and_more.py new file mode 100644 index 0000000..6acf011 --- /dev/null +++ b/backend/apps/annotations/migrations/0004_alter_bookmark_content_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.7 on 2026-06-04 03:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0003_bookmark_highlight_color'), + ] + + operations = [ + migrations.AlterField( + model_name='bookmark', + name='content', + field=models.TextField(blank=True, default='', help_text='Optional user thought; empty means bookmark-only'), + ), + migrations.AlterField( + model_name='bookmark', + name='highlight_color', + field=models.CharField(default='#fde047', help_text='Hex color for in-book passage highlight (e.g. #fde047)', max_length=7), + ), + ] diff --git a/backend/apps/books/serializers.py b/backend/apps/books/serializers.py index bc0ec9c..1efec8f 100644 --- a/backend/apps/books/serializers.py +++ b/backend/apps/books/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers import logging from apps.books.models import Book, BookChapter, EBook, ReadingProgress, ReadingStatus, DownloadRecord +from apps.books.services.ebook_metadata import subjects_from_ebook from apps.books.services.metadata import enrich_ebook_metadata from apps.books.services.process_ebook import apply_processing_to_ebook from apps.reader.models import ReadingSettings @@ -71,14 +72,18 @@ class EBookListSerializer(serializers.ModelSerializer): format = serializers.CharField(read_only=True) progress = serializers.SerializerMethodField() started = serializers.SerializerMethodField() + subjects = serializers.SerializerMethodField() class Meta: model = EBook fields = [ "id", "title", "author", "filename", "format", "page_count", "file_size", - "cover_image", "created_at", "progress", "started", + "cover_image", "created_at", "progress", "started", "subjects", ] + def get_subjects(self, obj: EBook) -> list[str]: + return subjects_from_ebook(obj) + def get_progress(self, obj): try: return obj.reading_progress.current_position diff --git a/backend/apps/books/services/ebook_metadata.py b/backend/apps/books/services/ebook_metadata.py new file mode 100644 index 0000000..cd0e7d3 --- /dev/null +++ b/backend/apps/books/services/ebook_metadata.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from apps.books.models import EBook + + +def subjects_from_ebook(ebook: EBook) -> list[str]: + ol = (ebook.metadata_json or {}).get("openlibrary") or {} + return [s.strip() for s in (ol.get("subjects") or []) if isinstance(s, str) and s.strip()] diff --git a/backend/apps/books/views.py b/backend/apps/books/views.py index ccbde5b..c5d1b22 100644 --- a/backend/apps/books/views.py +++ b/backend/apps/books/views.py @@ -21,6 +21,7 @@ from apps.books.serializers import ( ReadingProgressSerializer, StorageSummarySerializer, ) from apps.reader.models import ReadingSettings +from apps.books.services.ebook_metadata import subjects_from_ebook from apps.books.services.metadata import enrich_ebook_metadata logger = logging.getLogger(__name__) @@ -96,7 +97,30 @@ class EBookViewSet(viewsets.ModelViewSet): return EBookDetailSerializer def get_queryset(self): - return EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user") + qs = EBook.objects.filter(user=self.request.user).select_related("reading_progress", "user") + query = self.request.query_params.get("q", "").strip() + if query and self.action == "list": + qs = qs.filter( + Q(title__icontains=query) | Q(author__icontains=query), + ) + return qs + + @action(detail=False, methods=["get"]) + def genres(self, request: Request) -> Response: + genre_set: set[str] = set() + for ebook in self.get_queryset(): + genre_set.update(subjects_from_ebook(ebook)) + return Response(sorted(genre_set)) + + @action(detail=False, methods=["get"]) + def authors(self, request: Request) -> Response: + author_list = ( + self.get_queryset() + .values_list("author", flat=True) + .distinct() + .order_by("author") + ) + return Response([a for a in author_list if a]) @action(detail=True, methods=["get", "patch"]) def progress(self, request: Request, pk: int | None = None) -> Response: diff --git a/frontend/src/api/books.ts b/frontend/src/api/books.ts index 758c7a7..2775fff 100644 --- a/frontend/src/api/books.ts +++ b/frontend/src/api/books.ts @@ -44,15 +44,25 @@ export const booksApi = { }, async getGenres(): Promise { - const { data } = await api.get("/books/genres/"); + const { data } = await api.get("/books/ebooks/genres/"); return data; }, async getAuthors(): Promise { - const { data } = await api.get("/books/authors/"); + const { data } = await api.get("/books/ebooks/authors/"); return data; }, + async searchEBooks(params: { q: string; page_size?: number }): Promise { + const { data } = await api.get<{ count: number; results: EBookListItem[] } | EBookListItem[]>( + "/books/ebooks/", + { params: { q: params.q.trim() } }, + ); + const items = Array.isArray(data) ? data : data.results ?? []; + const limit = params.page_size ?? items.length; + return items.slice(0, limit); + }, + async uploadEBook( file: File, title: string, diff --git a/frontend/src/components/BookContextMenu.tsx b/frontend/src/components/BookContextMenu.tsx index 518a6e9..7c597cf 100644 --- a/frontend/src/components/BookContextMenu.tsx +++ b/frontend/src/components/BookContextMenu.tsx @@ -4,9 +4,15 @@ import { booksApi } from "../api/books"; import { getApiErrorMessage } from "../api/errors"; import { useToast } from "../hooks/useToast"; import type { BookListItem } from "../types/book"; +import type { SupportedLanguage } from "../locales"; +import { subjectsFromMetadata } from "../utils/ebookLibrary"; import styles from "./BookContextMenu.module.css"; -type LibraryBook = BookListItem & { format: string; progressPercent: number | null }; +type LibraryBook = BookListItem & { + format: string; + progressPercent: number | null; + subjects: string[]; +}; interface BookContextMenuProps { book: LibraryBook; @@ -37,7 +43,8 @@ export function BookContextMenu({ onBookUpdated, onBookRemoved, }: BookContextMenuProps) { - const { t } = useTranslation(); + const { t, language } = useTranslation(); + const locale = language as SupportedLanguage; const { showToast } = useToast(); const menuRef = useRef(null); const [position, setPosition] = useState({ left: x, top: y }); @@ -75,12 +82,14 @@ export function BookContextMenu({ setActiveAction("sync"); try { const updated = await booksApi.enrichEBookMetadata(book.id); + const subjects = subjectsFromMetadata(updated.metadata, locale); onBookUpdated({ ...book, id: updated.id, title: updated.title, author: updated.author, - genre: updated.format ? updated.format.toUpperCase() : "", + subjects, + genre: subjects[0] ?? "", format: updated.format, cover_image: updated.cover_image, progressPercent: book.progressPercent, diff --git a/frontend/src/components/search/SearchSuggestions.tsx b/frontend/src/components/search/SearchSuggestions.tsx index 720157f..8d84e04 100644 --- a/frontend/src/components/search/SearchSuggestions.tsx +++ b/frontend/src/components/search/SearchSuggestions.tsx @@ -3,20 +3,29 @@ import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18n-lite"; import { booksApi } from "../../api/books"; import { useDebounce } from "../../hooks/useDebounce"; -import type { BookListItem } from "../../types/book"; +import { ebookMatchesQuery } from "../../utils/ebookLibrary"; import styles from "./SearchSuggestions.module.css"; +interface SuggestionItem { + id: number; + title: string; + author: string; + cover_image: string | null; + format: string; +} + interface SearchSuggestionsProps { query: string; visible: boolean; onClose: () => void; onSelectSuggestion: () => void; + onPdfBook?: () => void; } -export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion }: SearchSuggestionsProps) { +export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion, onPdfBook }: SearchSuggestionsProps) { const { t } = useTranslation(); const navigate = useNavigate(); - const [suggestions, setSuggestions] = useState([]); + const [suggestions, setSuggestions] = useState([]); const [loading, setLoading] = useState(false); const containerRef = useRef(null); const debouncedQuery = useDebounce(query, 200); @@ -28,12 +37,21 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion } let cancelled = false; setLoading(true); - void booksApi.searchBooks({ q: debouncedQuery.trim(), page_size: 5 }).then( - (res) => { - if (!cancelled) { - setSuggestions(res.results); - setLoading(false); - } + void booksApi.getEBooks().then( + (ebooks) => { + if (cancelled) return; + const items = ebooks + .filter((e) => ebookMatchesQuery({ title: e.title, author: e.author, subjects: e.subjects ?? [] }, debouncedQuery)) + .slice(0, 5) + .map((e) => ({ + id: e.id, + title: e.title, + author: e.author, + cover_image: e.cover_image, + format: e.format, + })); + setSuggestions(items); + setLoading(false); }, () => { if (!cancelled) { @@ -72,6 +90,15 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion if (!visible || !query.trim()) return null; + const handleSelect = (book: SuggestionItem) => { + onSelectSuggestion(); + if (book.format === "pdf") { + onPdfBook?.(); + return; + } + navigate(`/read/${book.id}`); + }; + return (
{loading && ( @@ -88,10 +115,7 @@ export function SearchSuggestions({ query, visible, onClose, onSelectSuggestion
{ - onSelectSuggestion(); - navigate(`/books/${book.id}`); - }} + onClick={() => handleSelect(book)} > {book.cover_image ? ( diff --git a/frontend/src/pages/Library.tsx b/frontend/src/pages/Library.tsx index 5123460..400d10e 100644 --- a/frontend/src/pages/Library.tsx +++ b/frontend/src/pages/Library.tsx @@ -2,9 +2,16 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18n-lite"; import { booksApi } from "../api/books"; +import type { SupportedLanguage } from "../locales"; import type { BookListItem, BookSearchParams } from "../types/book"; import { useAuth } from "../context/AuthContext"; -import { deriveReadingStatus, normalizeProgressPercent } from "../utils/libraryStatus"; +import { + collectAuthorsFromEbooks, + collectGenresFromEbooks, + ebookMatchesQuery, + mapEbookToLibraryItem, + type EbookLibraryItem, +} from "../utils/ebookLibrary"; import { FinishedBooksShelf } from "../components/library/FinishedBooksShelf"; import { LibraryBookCard } from "../components/library/LibraryBookCard"; import { useDebounce } from "../hooks/useDebounce"; @@ -19,10 +26,7 @@ interface FilterState { reading_status: string; } -type LibraryBook = BookListItem & { - format: string; - progressPercent: number | null; -}; +type LibraryBook = BookListItem & EbookLibraryItem; interface ContextMenuState { book: LibraryBook; @@ -42,7 +46,8 @@ const TOUCH_TARGET: React.CSSProperties = { export function LibraryPage() { const navigate = useNavigate(); const { logout } = useAuth(); - const { t } = useTranslation(); + const { t, language } = useTranslation(); + const locale = language as SupportedLanguage; const [books, setBooks] = useState([]); const [loading, setLoading] = useState(true); @@ -58,7 +63,6 @@ export function LibraryPage() { const [contextMenu, setContextMenu] = useState(null); const debouncedSearch = useDebounce(searchQuery, 300); - const loadedRef = useRef(false); const searchInputRef = useRef(null); const isMobile = useMediaQuery(BREAKPOINTS.md); @@ -73,52 +77,20 @@ export function LibraryPage() { } }, [voiceSearch.transcript, voiceSearch.isListening]); - // 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 ebooks = await booksApi.getEBooks(); - let items: LibraryBook[] = ebooks.map((e) => { - const reading_status = deriveReadingStatus(e.progress, Boolean(e.started)); - const progressPercent = normalizeProgressPercent(e.progress, reading_status); - return { - id: e.id, - title: e.title, - author: e.author, - genre: e.format ? e.format.toUpperCase() : "", - format: e.format, - reading_status, - reading_status_display: reading_status, - cover_image: e.cover_image, - progressPercent, - }; - }); - const query = params.q?.trim().toLowerCase(); + setGenres(collectGenresFromEbooks(ebooks, locale)); + setAuthors(collectAuthorsFromEbooks(ebooks)); + let items: LibraryBook[] = ebooks.map((e) => mapEbookToLibraryItem(e, locale)); + const query = params.q?.trim(); if (query) { - items = items.filter( - (b) => - b.title.toLowerCase().includes(query) || - b.author.toLowerCase().includes(query) || - b.genre.toLowerCase().includes(query), - ); + items = items.filter((b) => ebookMatchesQuery(b, query)); } if (params.author) items = items.filter((b) => b.author === params.author); - if (params.genre) items = items.filter((b) => b.genre === params.genre); + if (params.genre) items = items.filter((b) => b.subjects.includes(params.genre!)); if (params.reading_status) { items = items.filter((b) => b.reading_status === params.reading_status); } @@ -131,7 +103,7 @@ export function LibraryPage() { } finally { setLoading(false); } - }, [t]); + }, [t, locale]); const statusFilterOptions = [ { value: "", label: t("library.allStatuses") }, @@ -141,7 +113,13 @@ export function LibraryPage() { { value: "dnf", label: t("library.readingStatus.dnf") }, ]; - // Reload when search or filters change + // Clear genre filter when it is not available in the current UI language + useEffect(() => { + if (!filters.genre) return; + setFilters((prev) => (prev.genre && !genres.includes(prev.genre) ? { ...prev, genre: "" } : prev)); + }, [language, genres, filters.genre]); + + // Reload when search, filters, or locale change useEffect(() => { const params: BookSearchParams = {}; if (debouncedSearch) params.q = debouncedSearch; @@ -149,7 +127,7 @@ export function LibraryPage() { if (filters.author) params.author = filters.author; if (filters.reading_status) params.reading_status = filters.reading_status; void loadBooks(params); - }, [debouncedSearch, filters, loadBooks]); + }, [debouncedSearch, filters, loadBooks, language]); const handleFilterChange = (key: keyof FilterState, value: string) => { setFilters((prev: FilterState) => ({ ...prev, [key]: value })); @@ -163,9 +141,14 @@ export function LibraryPage() { const hasActiveFilters = !!searchQuery || !!filters.genre || !!filters.author || !!filters.reading_status; + const refreshFilterOptions = useCallback((items: LibraryBook[]) => { + setGenres(collectGenresFromEbooks(items.map((b) => ({ subjects: b.subjects })), locale)); + setAuthors(collectAuthorsFromEbooks(items.map((b) => ({ author: b.author })))); + }, [locale]); + const handleBookUpdated = useCallback((updated: LibraryBook) => { - setBooks((prev) => - prev.map((b) => + setBooks((prev) => { + const next = prev.map((b) => b.id === updated.id ? { ...b, @@ -174,14 +157,20 @@ export function LibraryPage() { reading_status: updated.reading_status ?? b.reading_status, } : b, - ), - ); - }, []); + ); + refreshFilterOptions(next); + return next; + }); + }, [refreshFilterOptions]); const handleBookRemoved = useCallback((id: number) => { - setBooks((prev) => prev.filter((b) => b.id !== id)); + setBooks((prev) => { + const next = prev.filter((b) => b.id !== id); + refreshFilterOptions(next); + return next; + }); setTotalCount((count) => Math.max(0, count - 1)); - }, []); + }, [refreshFilterOptions]); const handleBookContextMenu = useCallback((e: React.MouseEvent, book: LibraryBook) => { e.preventDefault(); @@ -348,6 +337,7 @@ export function LibraryPage() { visible={showSuggestions && !voiceSearch.isListening} onClose={() => setShowSuggestions(false)} onSelectSuggestion={() => setShowSuggestions(false)} + onPdfBook={() => setReaderNotice(t("library.pdfNotice"))} />