This repository has been archived on 2026-07-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
cloud-reader/frontend/src/pages/Library.tsx
T
2026-06-04 06:42:27 -05:00

603 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<LibraryBook[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [readerNotice, setReaderNotice] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [filters, setFilters] = useState<FilterState>({ genre: "", author: "", reading_status: "" });
const [genres, setGenres] = useState<string[]>([]);
const [authors, setAuthors] = useState<string[]>([]);
const [showFilters, setShowFilters] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const debouncedSearch = useDebounce(searchQuery, 300);
const searchInputRef = useRef<HTMLInputElement>(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 (
<div style={containerStyle}>
{/* Header */}
<header style={headerStyle}>
<div>
<h1 style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: "#1f2937", margin: 0 }}>{t("library.title")}</h1>
{!loading && (
<p style={{ fontSize: 13, color: "#6b7280", marginTop: 2 }}>
{activeBooks.length === 1
? t("library.bookCountOne", { count: String(activeBooks.length) })
: t("library.bookCountMany", { count: String(activeBooks.length) })}
{finishedBooks.length > 0 && (
<span>
{" · "}
{t("library.finishedCountShort", { count: String(finishedBooks.length) })}
</span>
)}
</p>
)}
</div>
<div style={{ display: "flex", gap: isMobile ? 4 : 8, flexWrap: "wrap", alignItems: "center" }}>
{isMobile ? (
<>
<button onClick={() => navigate("/add")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.addBook")}></button>
<button onClick={() => navigate("/bookmarks-notes")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.bookmarks")}>🔖</button>
<button onClick={() => navigate("/settings")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.settings")}>⚙️</button>
<button onClick={logout} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title={t("library.logout")}>🚪</button>
</>
) : (
<>
<button onClick={() => navigate("/add")} className="btn">+ {t("library.addBook")}</button>
<button onClick={() => navigate("/bookmarks-notes")} className="btn btn-secondary">{t("library.bookmarks")}</button>
<button onClick={() => navigate("/settings")} className="btn btn-secondary">{t("library.settings")}</button>
<button onClick={logout} className="btn btn-danger">{t("library.logout")}</button>
</>
)}
</div>
</header>
{/* Search Bar */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div style={searchContainerStyle}>
<input
ref={searchInputRef}
type="text"
placeholder={t("library.searchPlaceholder")}
value={searchQuery}
onChange={(e) => {
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,
}}
/>
<span style={{
position: "absolute", left: 14, top: "50%", transform: "translateY(-50%)",
fontSize: 18, color: "#9ca3af", pointerEvents: "none",
}}>🔍</span>
{/* Voice Search Button */}
{voiceSearch.isSupported && (
<button
onClick={() => {
if (voiceSearch.isListening) {
voiceSearch.stopListening();
} else {
voiceSearch.startListening();
}
}}
style={{
position: "absolute",
right: 8,
top: "50%",
transform: "translateY(-50%)",
background: voiceSearch.isListening ? "#dc2626" : "transparent",
border: "none",
borderRadius: 8,
cursor: "pointer",
fontSize: 20,
padding: "8px 8px",
minWidth: 36,
minHeight: 36,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: voiceSearch.isListening ? "#fff" : "#6b7280",
transition: "background 0.15s",
}}
title={voiceSearch.isListening ? t("library.voiceSearchStopTitle") : t("library.voiceSearchTitle")}
>
🎤
</button>
)}
{/* Real-time Suggestions */}
<SearchSuggestions
query={searchQuery}
visible={showSuggestions && !voiceSearch.isListening}
onClose={() => setShowSuggestions(false)}
onSelectSuggestion={() => setShowSuggestions(false)}
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
style={{
...TOUCH_TARGET,
padding: "0 12px",
borderRadius: 8,
border: "1px solid #e5e7eb",
background: showFilters ? "#4f46e5" : "#fff",
color: showFilters ? "#fff" : "#374151",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
whiteSpace: "nowrap",
gap: 4,
}}
>
{isMobile ? "⚙️" : `▼ ${t("library.filtersLabel")}`}
</button>
</div>
</div>
{/* Voice search listening indicator */}
{voiceSearch.isListening && (
<div style={{
background: "#fef2f2",
padding: "10px 16px",
borderRadius: 8,
marginBottom: 12,
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 14,
color: "#dc2626",
}}>
<span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: "#dc2626", animation: "pulse 1s infinite" }} />
{t("library.listening")}
<button
onClick={voiceSearch.stopListening}
style={{
marginLeft: "auto",
background: "#dc2626",
color: "#fff",
border: "none",
borderRadius: 4,
padding: "4px 12px",
cursor: "pointer",
fontSize: 12,
}}
>
{t("library.stop")}
</button>
</div>
)}
{/* Voice search error */}
{voiceSearch.hasError && !voiceSearch.isListening && (
<div style={{
background: "#fef3c7",
padding: "8px 12px",
borderRadius: 8,
marginBottom: 12,
fontSize: 13,
color: "#92400e",
}}>
{voiceSearch.errorMessage === "no-speech" ? t("library.voiceNoSpeech") : t("library.voiceError", { message: voiceSearch.errorMessage ?? "" })}
</div>
)}
{/* Filters Panel */}
{showFilters && (
<div style={{
background: "#fff",
borderRadius: 10,
padding: isMobile ? 12 : 16,
marginBottom: 16,
border: "1px solid #e5e7eb",
display: "flex",
flexDirection: isMobile ? "column" : "row",
gap: 12,
alignItems: isMobile ? "stretch" : "end",
}}>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>{t("library.genre")}</label>
<select
value={filters.genre}
onChange={(e) => handleFilterChange("genre", e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}}
>
<option value="">{t("library.allGenres")}</option>
{genres.map((g) => <option key={g} value={g}>{g}</option>)}
</select>
</div>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>{t("library.author")}</label>
<select
value={filters.author}
onChange={(e) => handleFilterChange("author", e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}}
>
<option value="">{t("library.allAuthors")}</option>
{authors.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
<div style={{ minWidth: isMobile ? 0 : 160, flex: 1 }}>
<label style={{ display: "block", fontSize: 12, fontWeight: 600, color: "#6b7280", marginBottom: 4 }}>{t("library.status")}</label>
<select
value={filters.reading_status}
onChange={(e) => handleFilterChange("reading_status", e.target.value)}
style={{
width: "100%",
padding: "8px 12px",
borderRadius: 6,
border: "1px solid #e5e7eb",
fontSize: 14,
background: "#fff",
cursor: "pointer",
minHeight: 36,
}}
>
{statusFilterOptions.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
</div>
{hasActiveFilters && (
<button
onClick={clearAllFilters}
style={{
...TOUCH_TARGET,
padding: "8px 16px",
borderRadius: 6,
border: "1px solid #e5e7eb",
background: "#fff",
color: "#6b7280",
fontSize: 13,
cursor: "pointer",
whiteSpace: "nowrap",
width: isMobile ? "100%" : "auto",
}}
>
{t("library.clearFilters")}
</button>
)}
</div>
)}
{/* Error State */}
{error && (
<div style={{ background: "#fef2f2", padding: 16, borderRadius: 8, marginBottom: 16, textAlign: "center" }}>
<p style={{ color: "#dc2626", fontSize: 14 }}>{error}</p>
<button onClick={() => void loadBooks({})} style={{ marginTop: 8, padding: "8px 16px", border: "none", borderRadius: 6, background: "#dc2626", color: "#fff", cursor: "pointer", fontSize: 13, minHeight: 36 }}>{t("common.retry")}</button>
</div>
)}
{readerNotice && (
<div style={{ background: "#fffbeb", border: "1px solid #fde68a", borderRadius: 8, padding: "12px 16px", marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
<p style={{ color: "#92400e", fontSize: 14, margin: 0 }}>{readerNotice}</p>
<button type="button" onClick={() => setReaderNotice(null)} style={{ padding: "4px 10px", border: "none", borderRadius: 6, background: "#f59e0b", color: "#fff", cursor: "pointer", fontSize: 12, flexShrink: 0 }}>{t("common.dismiss")}</button>
</div>
)}
{/* Loading State */}
{loading && (
<div style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(200px, 1fr))",
gap: isMobile ? 12 : 16,
opacity: 0.6,
}}>
{Array.from({ length: isMobile ? 4 : 8 }).map((_, i) => (
<div key={i} style={{ background: "#fff", borderRadius: 12, overflow: "hidden", boxShadow: "0 2px 8px rgba(0,0,0,0.06)", display: isMobile ? "flex" : "block" }}>
<div style={{ width: isMobile ? 80 : "100%", height: isMobile ? 120 : 180, background: "#f0f0f0", flexShrink: 0 }} />
<div style={{ padding: 12 }}>
<div style={{ height: 14, background: "#f0f0f0", borderRadius: 4, marginBottom: 6, width: "70%" }} />
<div style={{ height: 12, background: "#f0f0f0", borderRadius: 4, width: "40%" }} />
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{!loading && !error && books.length === 0 && (
<div style={{ textAlign: "center", padding: isMobile ? "60px 16px" : "80px 20px" }}>
<div style={{ fontSize: isMobile ? 48 : 64, marginBottom: 16 }}>{hasActiveFilters ? "🔍" : "📚"}</div>
<h2 style={{ fontSize: isMobile ? 18 : 20, color: "#1f2937", marginBottom: 8 }}>
{hasActiveFilters ? t("library.emptyFilteredTitle") : t("library.emptyTitle")}
</h2>
<p style={{ color: "#6b7280", marginBottom: 20, fontSize: 15, lineHeight: 1.5 }}>
{hasActiveFilters
? t("library.emptyFilteredDescription")
: t("library.emptyDescription")}
</p>
{hasActiveFilters ? (
<button onClick={clearAllFilters} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>
{t("library.clearAllFilters")}
</button>
) : (
<button onClick={() => navigate("/add")} className="btn" style={{ padding: "12px 24px", fontSize: 15, minHeight: 44 }}>
{t("library.addFirstBook")}
</button>
)}
</div>
)}
{!loading && activeBooks.length === 0 && finishedBooks.length > 0 && !hasActiveFilters && (
<p style={{ textAlign: "center", color: "#6b7280", fontSize: 14, marginBottom: 16 }}>
{t("library.allInFinishedShelf")}
</p>
)}
{/* Active library grid (want to read, reading) */}
{!loading && activeBooks.length > 0 && (
<div
style={{
display: "grid",
gridTemplateColumns: isMobile ? "1fr" : "repeat(auto-fill, minmax(200px, 1fr))",
gap: isMobile ? 12 : 16,
}}
>
{activeBooks.map((book) => (
<LibraryBookCard
key={book.id}
book={book}
isMobile={isMobile}
onOpen={() => openBook(book)}
onContextMenu={(e) => handleBookContextMenu(e, book)}
/>
))}
</div>
)}
{shelfVisible && (
<FinishedBooksShelf
books={finishedBooks}
defaultExpanded={finishedFilterActive}
onOpenBook={(book) => openBook(book as LibraryBook)}
onContextMenu={(e, book) => handleBookContextMenu(e, book as LibraryBook)}
/>
)}
{contextMenu && (
<BookContextMenu
book={contextMenu.book}
x={contextMenu.x}
y={contextMenu.y}
onClose={() => setContextMenu(null)}
onBookUpdated={handleBookUpdated}
onBookRemoved={handleBookRemoved}
/>
)}
</div>
);
}