fix: floating bookmarks

- update floating bookmarks
- fix search suggestions
- fix filters
This commit is contained in:
2026-06-03 22:33:24 -05:00
parent 054e7689bd
commit d9c66e68a6
11 changed files with 296 additions and 75 deletions
+45 -55
View File
@@ -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<LibraryBook[]>([]);
const [loading, setLoading] = useState(true);
@@ -58,7 +63,6 @@ export function LibraryPage() {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const debouncedSearch = useDebounce(searchQuery, 300);
const loadedRef = useRef(false);
const searchInputRef = useRef<HTMLInputElement>(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"))}
/>
</div>
<button