import { useCallback, useEffect, useRef, useState, type FC } from "hono/jsx/dom"; import type { HiveBook } from "../../types"; import { SearchPalette } from "./SearchPalette"; import { PersonalBookCard } from "./library/PersonalBookCard"; import { ShelfTabs } from "./library/ShelfTabs"; import { AlsoTracking, SyncTriage } from "./library/SyncDocumentSections"; import type { PersonalBook, Shelf, SyncDoc } from "./library/types"; const PAGE_SIZE = 24; /** What the SearchPalette selection should be applied to once a book is picked. */ type LinkTarget = { kind: "book"; contentHash: string } | { kind: "document"; document: string }; const postJson = (url: string, body: unknown): Promise => fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); /** * These endpoints sit behind the app's global `etag` middleware, so a plain * re-fetch of the same URL after a mutation is served from the browser cache and * shows pre-mutation state (a dismissed document reappearing in the triage * strip, say). Always go to the network — this data is per-user and private. */ const getJson = (url: string): Promise => fetch(url, { cache: "no-store" }); /** * The personal library manager: the OPDS catalog and e-reader sync progress in * one view. * * Personal books and synced documents are keyed by the same KOReader partial * MD5, so a document with a matching file is folded into the grid card (as a * progress bar) rather than listed separately. Documents with no file are * triaged above the grid, or parked in "Also tracking" below it once the user * has linked or dismissed them. */ function formatBytes(bytes: number): string { if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`; if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MB`; return `${Math.round(bytes / 1024)} KB`; } const StorageMeter: FC<{ storage: { usedBytes: number; quotaBytes: number } | null }> = ({ storage, }) => { if (!storage || storage.quotaBytes <= 0) return null; const ratio = Math.min(1, storage.usedBytes / storage.quotaBytes); const pct = Math.round(ratio * 100); const full = ratio >= 1; const barColor = full ? "bg-destructive" : ratio >= 0.8 ? "bg-amber-500" : "bg-primary"; return (
{formatBytes(storage.usedBytes)} of {formatBytes(storage.quotaBytes)} used {pct}%
{full && Library full — delete a book to upload more.}
); }; export const LibraryManager: FC = () => { const [books, setBooks] = useState(null); const [cursor, setCursor] = useState(undefined); const [loadingMore, setLoadingMore] = useState(false); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(false); const [docs, setDocs] = useState([]); const [shelves, setShelves] = useState([]); const [shelvesLoaded, setShelvesLoaded] = useState(false); const [totalBooks, setTotalBooks] = useState(0); const [storage, setStorage] = useState<{ usedBytes: number; quotaBytes: number } | null>(null); const [activeShelfId, setActiveShelfId] = useState(null); const [linkTarget, setLinkTarget] = useState(null); const openPaletteRef = useRef<((initialQuery?: string) => void) | null>(null); // ── Fetching ── const libraryUrl = (shelfId: number | null, from?: string): string => { const params = new URLSearchParams({ limit: String(PAGE_SIZE) }); if (shelfId != null) params.set("shelfId", String(shelfId)); if (from) params.set("cursor", from); return `/xrpc/buzz.bookhive.getPersonalLibrary?${params}`; }; const fetchBooks = useCallback((shelfId: number | null) => { // Deliberately not clearing `books` here: blanking the grid on every tab // switch causes a full-height layout jump. We dim instead. setRefreshing(true); setError(false); getJson(libraryUrl(shelfId)) .then((r) => { if (!r.ok) throw new Error("Failed"); return r.json(); }) .then( (d: { books: PersonalBook[]; total?: number; cursor?: string; storage?: { usedBytes: number; quotaBytes: number }; }) => { setBooks(d.books); setCursor(d.cursor); if (shelfId === null) setTotalBooks(d.total ?? d.books.length); // Rides along on the list every client already refetches after each // mutation, so the usage bar stays current with no extra request. if (d.storage) setStorage(d.storage); }, ) .catch(() => setError(true)) .finally(() => setRefreshing(false)); }, []); const loadMore = () => { if (!cursor || loadingMore) return; setLoadingMore(true); getJson(libraryUrl(activeShelfId, cursor)) .then((r) => { if (!r.ok) throw new Error("Failed"); return r.json(); }) .then((d: { books: PersonalBook[]; cursor?: string }) => { setBooks((prev) => [...(prev ?? []), ...d.books]); setCursor(d.cursor); }) .catch(() => { // leave the cursor in place so the user can retry }) .finally(() => setLoadingMore(false)); }; const fetchShelves = useCallback(() => { getJson("/library/shelves") .then((r) => { if (!r.ok) throw new Error("Failed"); return r.json(); }) .then((d: { shelves: Shelf[] }) => setShelves(d.shelves)) .catch(() => { // a missing shelf list just means no tabs }) .finally(() => setShelvesLoaded(true)); }, []); const fetchDocs = useCallback(() => { getJson("/library/sync/documents") .then((r) => { if (!r.ok) throw new Error("Failed"); return r.json(); }) .then((d: { documents: SyncDoc[] }) => setDocs(d.documents)) .catch(() => setDocs([])); }, []); useEffect(() => { fetchBooks(activeShelfId); }, [activeShelfId, fetchBooks]); useEffect(() => { fetchShelves(); fetchDocs(); }, [fetchShelves, fetchDocs]); // Uploading is a full form POST + redirect, so returning to this page from // the bfcache would otherwise show a stale grid. useEffect(() => { const onPageShow = (e: PageTransitionEvent) => { if (!e.persisted) return; fetchBooks(activeShelfId); fetchShelves(); fetchDocs(); }; window.addEventListener("pageshow", onPageShow); return () => window.removeEventListener("pageshow", onPageShow); }, [activeShelfId, fetchBooks, fetchShelves, fetchDocs]); // ── Book actions ── const patchBook = (contentHash: string, patch: Partial) => setBooks((prev) => (prev ?? []).map((b) => (b.contentHash === contentHash ? { ...b, ...patch } : b)), ); const handleUnlink = async (contentHash: string) => { try { const res = await postJson("/xrpc/buzz.bookhive.unlinkPersonalBook", { contentHash }); if (!res.ok) throw new Error("Failed"); patchBook(contentHash, { hiveId: null }); fetchDocs(); } catch { // the card stays linked and the user can retry } }; const handleDelete = async (contentHash: string) => { const previous = books; setBooks((prev) => (prev ?? []).filter((b) => b.contentHash !== contentHash)); setTotalBooks((n) => Math.max(0, n - 1)); try { const res = await postJson("/xrpc/buzz.bookhive.deletePersonalBook", { contentHash }); if (!res.ok) throw new Error("Failed"); // Refetch rather than subtract the deleted size locally: `storage` is the // server's `SUM(sizeBytes)`, and the list response carries it for free. // Without this the usage bar keeps showing the freed space as used, and a // user who deletes a book to get under quota still can't upload. fetchBooks(activeShelfId); // The file is gone but its sync document may live on, so it can reappear // in the triage/tracking sections. fetchDocs(); fetchShelves(); } catch { setBooks(previous); setTotalBooks((n) => n + 1); } }; // ── Linking (shared by grid cards and sync document rows) ── const startLinkBook = (book: PersonalBook) => { setLinkTarget({ kind: "book", contentHash: book.contentHash }); openPaletteRef.current?.(book.title); }; const startLinkDoc = (doc: SyncDoc) => { setLinkTarget({ kind: "document", document: doc.document }); openPaletteRef.current?.(doc.title || doc.filename || undefined); }; const handleSelectBook = async (hiveBook: HiveBook) => { const target = linkTarget; setLinkTarget(null); if (!target) return; try { if (target.kind === "book") { const res = await postJson("/xrpc/buzz.bookhive.linkPersonalBook", { contentHash: target.contentHash, hiveId: hiveBook.id, }); if (!res.ok) throw new Error("Failed"); // Linking overwrites the file's title/authors with the hive book's, so // re-fetch rather than guessing at the new values. fetchBooks(activeShelfId); } else { const res = await postJson("/library/sync/link", { document: target.document, hiveId: hiveBook.id, }); if (!res.ok) throw new Error("Failed"); } fetchDocs(); } catch { // nothing changes; the row keeps its "Link to book" button } }; // ── Sync document actions ── /** * Mark a document as having no BookHive counterpart. There is no un-dismiss * action: linking the document to a book overwrites the sentinel, which is * the only correction worth offering. The endpoint still accepts * `dismissed: false` for completeness. */ const dismissDocument = async (document: string) => { const previous = docs; setDocs((prev) => prev.map((d) => (d.document === document ? { ...d, dismissed: true, hiveId: null } : d)), ); try { const res = await postJson("/library/sync/dismiss", { document, dismissed: true }); if (!res.ok) throw new Error("Failed"); } catch { setDocs(previous); } }; const handleRenameDoc = async (document: string, title: string) => { const previous = docs; setDocs((prev) => prev.map((d) => (d.document === document ? { ...d, title } : d))); try { const res = await postJson("/library/sync/rename", { document, title }); if (!res.ok) throw new Error("Failed"); } catch { setDocs(previous); } }; /** Discard the e-reader progress we hold for a document. */ const handleDeleteDoc = async (document: string) => { const previous = docs; setDocs((prev) => prev.filter((d) => d.document !== document)); try { const res = await postJson("/library/sync/delete", { document }); if (!res.ok) throw new Error("Failed"); } catch { setDocs(previous); } }; // ── Shelf actions ── const handleCreateShelf = async (name: string): Promise => { try { const res = await postJson("/xrpc/buzz.bookhive.createPersonalShelf", { name }); if (!res.ok) { return res.status === 409 || res.status === 400 ? "A shelf with that name already exists." : "Could not create shelf."; } const data = (await res.json()) as { shelf: Shelf }; setShelves((prev) => [...prev, data.shelf].sort((a, b) => a.name.localeCompare(b.name))); return null; } catch { return "Could not create shelf."; } }; const handleRenameShelf = async (id: number, name: string): Promise => { try { const res = await postJson("/xrpc/buzz.bookhive.updatePersonalShelf", { id, name }); if (!res.ok) { return res.status === 409 || res.status === 400 ? "A shelf with that name already exists." : "Could not rename shelf."; } const data = (await res.json()) as { shelf: Shelf }; setShelves((prev) => prev .map((s) => (s.id === id ? data.shelf : s)) .sort((a, b) => a.name.localeCompare(b.name)), ); return null; } catch { return "Could not rename shelf."; } }; const handleDeleteShelf = async (id: number): Promise => { const previous = shelves; setShelves((prev) => prev.filter((s) => s.id !== id)); if (activeShelfId === id) setActiveShelfId(null); setBooks((prev) => (prev ?? []).map((b) => ({ ...b, shelfIds: (b.shelfIds ?? []).filter((s) => s !== id) })), ); try { const res = await postJson("/xrpc/buzz.bookhive.deletePersonalShelf", { id }); if (!res.ok) throw new Error("Failed"); } catch { setShelves(previous); fetchBooks(activeShelfId); } }; const setMembership = (contentHash: string, shelfId: number, member: boolean) => { setBooks((prev) => (prev ?? []).map((b) => { if (b.contentHash !== contentHash) return b; const ids = new Set(b.shelfIds ?? []); if (member) ids.add(shelfId); else ids.delete(shelfId); return { ...b, shelfIds: [...ids] }; }), ); setShelves((prev) => prev.map((s) => s.id === shelfId ? { ...s, bookCount: Math.max(0, s.bookCount + (member ? 1 : -1)) } : s, ), ); }; const handleAddToShelf = async (shelfId: number, contentHash: string) => { setMembership(contentHash, shelfId, true); try { const res = await postJson("/xrpc/buzz.bookhive.addToPersonalShelf", { shelfId, contentHash, }); if (!res.ok) throw new Error("Failed"); } catch { setMembership(contentHash, shelfId, false); } }; const handleRemoveFromShelf = async (shelfId: number, contentHash: string) => { setMembership(contentHash, shelfId, false); // When browsing that shelf, the book also leaves the current view. const wasVisible = activeShelfId === shelfId; if (wasVisible) setBooks((prev) => (prev ?? []).filter((b) => b.contentHash !== contentHash)); try { const res = await postJson("/xrpc/buzz.bookhive.removeFromPersonalShelf", { shelfId, contentHash, }); if (!res.ok) throw new Error("Failed"); } catch { setMembership(contentHash, shelfId, true); if (wasVisible) fetchBooks(activeShelfId); } }; // ── Partition the sync documents ── // Documents backed by a file are already represented by their grid card. const orphanDocs = docs.filter((d) => !d.hasFile); const triageDocs = orphanDocs.filter((d) => !d.hiveId && !d.dismissed); const trackedDocs = orphanDocs.filter((d) => d.hiveId || d.dismissed); const isEmptyGrid = !error && books !== null && books.length === 0; return (
void dismissDocument(document)} /> {shelvesLoaded && ( )} {error && (

Could not load your library. Try reloading the page.

)} {!error && books === null && (

Loading your library...

)} {isEmptyGrid && (

{activeShelfId !== null ? 'No books on this shelf yet. Add them from the "All books" tab.' : 'No books in your library yet. Use "Upload books" to add one.'}

)} {books !== null && books.length > 0 && ( <>
    {books.map((book) => ( void handleUnlink(hash)} onDelete={(hash) => void handleDelete(hash)} onAddToShelf={(shelfId, hash) => void handleAddToShelf(shelfId, hash)} onRemoveFromShelf={(shelfId, hash) => void handleRemoveFromShelf(shelfId, hash)} /> ))}
{cursor && (
)} )} void handleDeleteDoc(document)} /> { openPaletteRef.current = fn; }} onSelectBook={(book) => void handleSelectBook(book)} placeholder="Search for the matching book..." />
); };