Archived
fix: floating bookmarks
- update floating bookmarks - fix search suggestions - fix filters
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user