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 { 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"; import { useVoiceSearch } from "../hooks/useVoiceSearch"; import { useMediaQuery, BREAKPOINTS } from "../hooks/useMediaQuery"; import { SearchSuggestions } from "../components/search/SearchSuggestions"; import { BookContextMenu } from "../components/BookContextMenu"; interface FilterState { genre: string; author: string; reading_status: string; } type LibraryBook = BookListItem & EbookLibraryItem; interface ContextMenuState { book: LibraryBook; x: number; y: number; } /** WCAG 2.1 minimum touch target */ const TOUCH_TARGET: React.CSSProperties = { minHeight: 44, minWidth: 44, display: "inline-flex", alignItems: "center", justifyContent: "center", }; export function LibraryPage() { const navigate = useNavigate(); const { logout } = useAuth(); const { t, language } = useTranslation(); const locale = language as SupportedLanguage; const [books, setBooks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [readerNotice, setReaderNotice] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [filters, setFilters] = useState({ genre: "", author: "", reading_status: "" }); const [genres, setGenres] = useState([]); const [authors, setAuthors] = useState([]); const [showFilters, setShowFilters] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false); const [contextMenu, setContextMenu] = useState(null); const debouncedSearch = useDebounce(searchQuery, 300); const searchInputRef = useRef(null); const isMobile = useMediaQuery(BREAKPOINTS.md); // Voice search const voiceSearch = useVoiceSearch(); // Sync voice transcript into search input useEffect(() => { if (voiceSearch.transcript && !voiceSearch.isListening) { setSearchQuery(voiceSearch.transcript); setShowSuggestions(false); } }, [voiceSearch.transcript, voiceSearch.isListening]); const loadBooks = useCallback(async (params: BookSearchParams) => { setLoading(true); setError(null); try { const ebooks = await booksApi.getEBooks(); 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) => ebookMatchesQuery(b, query)); } if (params.author) items = items.filter((b) => b.author === params.author); 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); } setBooks(items); } catch (err) { setError(err instanceof Error ? err.message : t("library.loadFailed")); setBooks([]); } finally { setLoading(false); } }, [t, locale]); const statusFilterOptions = [ { value: "", label: t("library.allStatuses") }, { value: "want_to_read", label: t("library.readingStatus.wantToRead") }, { value: "reading", label: t("library.readingStatus.reading") }, { value: "finished", label: t("library.readingStatus.finished") }, { value: "dnf", label: t("library.readingStatus.dnf") }, ]; // 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; 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, language]); const handleFilterChange = (key: keyof FilterState, value: string) => { setFilters((prev: FilterState) => ({ ...prev, [key]: value })); }; const clearAllFilters = () => { setSearchQuery(""); setFilters({ genre: "", author: "", reading_status: "" }); setShowFilters(false); }; 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) => { const next = prev.map((b) => b.id === updated.id ? { ...b, ...updated, progressPercent: updated.progressPercent ?? b.progressPercent, reading_status: updated.reading_status ?? b.reading_status, } : b, ); refreshFilterOptions(next); return next; }); }, [refreshFilterOptions]); const handleBookRemoved = useCallback((id: number) => { setBooks((prev) => { const next = prev.filter((b) => b.id !== id); refreshFilterOptions(next); return next; }); }, [refreshFilterOptions]); const handleBookContextMenu = useCallback((e: React.MouseEvent, book: LibraryBook) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ book, x: e.clientX, y: e.clientY }); }, []); const activeBooks = useMemo( () => books.filter((b) => b.reading_status !== "finished"), [books], ); const finishedBooks = useMemo( () => books.filter((b) => b.reading_status === "finished"), [books], ); const shelfVisible = finishedBooks.length > 0; const finishedFilterActive = filters.reading_status === "finished"; const openBook = useCallback( (book: LibraryBook) => { navigate(`/read/${book.id}`); }, [navigate], ); const containerStyle: React.CSSProperties = { maxWidth: 960, margin: "0 auto", padding: isMobile ? 12 : 16, paddingBottom: shelfVisible ? (isMobile ? 72 : 68) : undefined, minHeight: "100vh", background: "#f8f9fa", }; const headerStyle: React.CSSProperties = { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: isMobile ? 12 : 20, padding: isMobile ? "12px 0" : "16px 0", borderBottom: "1px solid #e5e7eb", flexWrap: "wrap", gap: 8, }; const searchContainerStyle: React.CSSProperties = { position: "relative", flex: 1, }; return (
{/* Header */}

{t("library.title")}

{!loading && (

{activeBooks.length === 1 ? t("library.bookCountOne", { count: String(activeBooks.length) }) : t("library.bookCountMany", { count: String(activeBooks.length) })} {finishedBooks.length > 0 && ( {" ยท "} {t("library.finishedCountShort", { count: String(finishedBooks.length) })} )}

)}
{isMobile ? ( <> ) : ( <> )}
{/* Search Bar */}
{ setSearchQuery(e.target.value); setShowSuggestions(true); }} onFocus={() => setShowSuggestions(true)} style={{ width: "100%", padding: `12px 16px 12px ${voiceSearch.isSupported ? 44 : 44}px`, paddingRight: voiceSearch.isSupported ? 48 : 16, borderRadius: 10, border: "1px solid #e5e7eb", fontSize: isMobile ? 16 : 15, background: "#fff", outline: "none", boxSizing: "border-box", minHeight: 44, }} /> ๐Ÿ” {/* Voice Search Button */} {voiceSearch.isSupported && ( )} {/* Real-time Suggestions */} setShowSuggestions(false)} onSelectSuggestion={() => setShowSuggestions(false)} />
{/* Voice search listening indicator */} {voiceSearch.isListening && (
{t("library.listening")}
)} {/* Voice search error */} {voiceSearch.hasError && !voiceSearch.isListening && (
{voiceSearch.errorMessage === "no-speech" ? t("library.voiceNoSpeech") : t("library.voiceError", { message: voiceSearch.errorMessage ?? "" })}
)} {/* Filters Panel */} {showFilters && (
{hasActiveFilters && ( )}
)} {/* Error State */} {error && (

{error}

)} {readerNotice && (

{readerNotice}

)} {/* Loading State */} {loading && (
{Array.from({ length: isMobile ? 4 : 8 }).map((_, i) => (
))}
)} {/* Empty State */} {!loading && !error && books.length === 0 && (
{hasActiveFilters ? "๐Ÿ”" : "๐Ÿ“š"}

{hasActiveFilters ? t("library.emptyFilteredTitle") : t("library.emptyTitle")}

{hasActiveFilters ? t("library.emptyFilteredDescription") : t("library.emptyDescription")}

{hasActiveFilters ? ( ) : ( )}
)} {!loading && activeBooks.length === 0 && finishedBooks.length > 0 && !hasActiveFilters && (

{t("library.allInFinishedShelf")}

)} {/* Active library grid (want to read, reading) */} {!loading && activeBooks.length > 0 && (
{activeBooks.map((book) => ( openBook(book)} onContextMenu={(e) => handleBookContextMenu(e, book)} /> ))}
)} {shelfVisible && ( openBook(book as LibraryBook)} onContextMenu={(e, book) => handleBookContextMenu(e, book as LibraryBook)} /> )} {contextMenu && ( setContextMenu(null)} onBookUpdated={handleBookUpdated} onBookRemoved={handleBookRemoved} /> )}
); }