Archived
Backend: - Add groups Django app with models: Group, GroupMember, GroupInvite, JoinRequest - Create serializers with business rule validation - Implement GroupViewSet with full CRUD + custom actions (members, invites, roles, leave, join requests) - Add JoinGroupViewSet for invite-based joining flow - Register app in Django config and URL routing Frontend: - Add shared types for groups to @cloud-reader/shared - Create groups API client (groupsApi) - Build GroupsListPage, GroupDetailPage (member mgmt, invites, role transfer) - Build CreateGroupPage and JoinGroupPage - Add lazy-loaded routes to App.tsx with ProtectedRoute - Add navigation links to Library header Ref: #28
605 lines
23 KiB
TypeScript
605 lines
23 KiB
TypeScript
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("/groups")} style={{ ...TOUCH_TARGET, fontSize: 20, background: "none", border: "none", cursor: "pointer", padding: 8 }} title="Groups">👥</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("/groups")} className="btn btn-secondary">Groups</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>
|
||
);
|
||
} |