import { useMemo, useState, useRef, useEffect, type FC } from "hono/jsx/dom"; import { ABANDONED, FINISHED, READING, WANTTOREAD } from "../../constants"; import { StatusSelect, RatingSelect, DeleteButton, BookCover, DateInput, updateBook, deleteBook, } from "./bookActions"; type BookProgressData = { percent?: number; totalPages?: number; currentPage?: number; totalChapters?: number; currentChapter?: number; updatedAt?: string; } | null; type LibraryBook = { hiveId: string; title: string; authors: string; cover?: string | null; thumbnail?: string | null; status: string | null; stars: number | null; startedAt: string | null; finishedAt: string | null; createdAt: string; owned: number; review: string | null; bookProgress: BookProgressData; totalPages: number | null; }; type SortKey = "default" | "title" | "status" | "rating" | "date"; type SortDir = "asc" | "desc"; const STATUS_ORDER: Record = { [READING]: 0, [WANTTOREAD]: 1, [FINISHED]: 2, [ABANDONED]: 3, }; function compareBooks(a: LibraryBook, b: LibraryBook, key: SortKey, dir: SortDir): number { let cmp = 0; switch (key) { case "title": cmp = a.title.localeCompare(b.title); break; case "status": { const aOrd = a.status ? (STATUS_ORDER[a.status] ?? 99) : 99; const bOrd = b.status ? (STATUS_ORDER[b.status] ?? 99) : 99; cmp = aOrd - bOrd; if (cmp === 0) cmp = a.title.localeCompare(b.title); break; } case "rating": cmp = (a.stars ?? -1) - (b.stars ?? -1); break; case "date": { const aDate = a.finishedAt || a.startedAt; const bDate = b.finishedAt || b.startedAt; if (!aDate && !bDate) cmp = 0; // Undated books sink to the bottom regardless of direction. else if (!aDate) return 1; else if (!bDate) return -1; else cmp = new Date(aDate).getTime() - new Date(bDate).getTime(); break; } default: { const aIsReading = a.status === READING; const bIsReading = b.status === READING; if (aIsReading !== bIsReading) return aIsReading ? -1 : 1; const aIsFinished = a.status === FINISHED; const bIsFinished = b.status === FINISHED; if (aIsFinished && bIsFinished) { if (!a.finishedAt && !b.finishedAt) return 0; if (!a.finishedAt) return 1; if (!b.finishedAt) return -1; return new Date(b.finishedAt).getTime() - new Date(a.finishedAt).getTime(); } return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); } } return dir === "desc" ? -cmp : cmp; } const SortArrow: FC<{ active: boolean; dir: SortDir }> = ({ active, dir }) => ( {dir === "asc" ? : } ); // --- Progress input --- const PageInput: FC<{ book: LibraryBook; onUpdate: (fields: Partial) => void; }> = ({ book, onUpdate }) => { const total = book.bookProgress?.totalPages ?? book.totalPages; const [currentPage, setCurrentPage] = useState(book.bookProgress?.currentPage ?? ""); const inputRef = useRef(null); const percent = book.status === FINISHED ? 100 : currentPage && total ? Math.round((Number(currentPage) / total) * 100) : (book.bookProgress?.percent ?? 0); const submitProgress = () => { // An empty field is "no change", not page 0 — Number("") is 0, which would // silently overwrite the recorded percent with 0. if (currentPage === "" || currentPage == null) return; const page = Number(currentPage); if (!Number.isFinite(page) || page <= 0) return; if (page === book.bookProgress?.currentPage) return; const progress = { currentPage: page, totalPages: total || undefined, percent: total ? Math.round((page / total) * 100) : undefined, }; onUpdate({ bookProgress: { ...book.bookProgress, ...progress, updatedAt: new Date().toISOString() }, }); // Omit a null status: the server schema is z.optional(z.string()), which // rejects an explicit null with a 400 the fire-and-forget call never surfaces. // The server defaults status to READING when progress is present. void updateBook(book.hiveId, { bookProgress: progress, ...(book.status ? { status: book.status } : {}), }); }; if (book.status === FINISHED) { return (
Finished
); } return (
{percent > 0 && (
)}
setCurrentPage((e.target as HTMLInputElement).value)} onBlur={submitProgress} onKeyDown={(e) => { if (e.key === "Enter") { (e.target as HTMLInputElement).blur(); } }} /> {total ? ( / {total} ) : null}
); }; // --- Desktop row --- const TableRow: FC<{ book: LibraryBook; onUpdate: (fields: Partial) => void; onDelete: () => void; }> = ({ book, onUpdate, onDelete }) => ( (window.location.href = `/books/${book.hiveId}`)} >

{book.title}

{book.authors.split("\t").join(", ")}

e.stopPropagation()}> { onUpdate({ status }); void updateBook(book.hiveId, { status }); }} /> e.stopPropagation()}> { onUpdate({ stars }); void updateBook(book.hiveId, { stars }); }} /> e.stopPropagation()}> e.stopPropagation()}>
Date started { onUpdate({ startedAt }); void updateBook(book.hiveId, { startedAt }); }} />
Date finished { onUpdate({ finishedAt }); void updateBook(book.hiveId, { finishedAt }); }} />
e.stopPropagation()}> ); // --- Mobile card --- const MobileCard: FC<{ book: LibraryBook; onUpdate: (fields: Partial) => void; onDelete: () => void; }> = ({ book, onUpdate, onDelete }) => { const [savingOwned, setSavingOwned] = useState(false); const [ownedError, setOwnedError] = useState(false); const total = book.bookProgress?.totalPages ?? book.totalPages; const currentPage = book.bookProgress?.currentPage; const percent = book.status === FINISHED ? 100 : currentPage && total ? Math.round((currentPage / total) * 100) : (book.bookProgress?.percent ?? 0); return (
{book.title}
{book.authors.split("\t").join(", ")}
{percent > 0 && book.status !== FINISHED && (
{percent}% read
)}
e.stopPropagation()} > { onUpdate({ status }); void updateBook(book.hiveId, { status }); }} /> {book.status === READING && (
)}
{ownedError && (

Could not save ownership. Please try again.

)}
); }; // --- Main component --- export const LibraryTable: FC<{ initialBooks: LibraryBook[] }> = ({ initialBooks }) => { const [books, setBooks] = useState(initialBooks); const [pendingDelete, setPendingDelete] = useState(null); const [removing, setRemoving] = useState(false); const [deleteError, setDeleteError] = useState(false); const deleteDialog = useRef(null); useEffect(() => { if (pendingDelete) deleteDialog.current?.showModal(); }, [pendingDelete]); const requestDelete = (book: LibraryBook) => { setDeleteError(false); setPendingDelete(book); }; const [sortKey, setSortKey] = useState("default"); const [sortDir, setSortDir] = useState("asc"); const toggleSort = (key: SortKey) => { if (sortKey === key) { setSortDir((d) => (d === "asc" ? "desc" : "asc")); } else { setSortKey(key); // Rating and date start highest/newest first; text columns start A-Z. setSortDir(key === "rating" || key === "date" ? "desc" : "asc"); } }; const sortedBooks = useMemo(() => { return [...books].sort((a, b) => compareBooks(a, b, sortKey, sortDir)); }, [books, sortKey, sortDir]); const updateBook_ = (hiveId: string, fields: Partial) => { setBooks((prev) => prev.map((b) => (b.hiveId === hiveId ? { ...b, ...fields } : b))); }; const deleteBook_ = (hiveId: string) => { setBooks((prev) => prev.filter((b) => b.hiveId !== hiveId)); }; if (!books.length) { return (

No books in your library yet. Start adding books to see them here!

); } return ( <> setPendingDelete(null)} onCancel={(event: Event) => { if (removing) event.preventDefault(); }} className="fixed top-1/2 left-1/2 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-card p-6 text-card-foreground shadow-lg backdrop:bg-black/50" >

Remove book?

This will remove “{pendingDelete?.title}” from your library. This cannot be undone.

{deleteError && (

Could not remove this book. Please try again.

)}
{/* Desktop: table view. Bounded scroll container: caps height to the viewport so the header pins reliably (sticky resolves against this box's scrollport, not the document — the app shell scrolls at document level, and an overflow-hidden ancestor here would sink the sticky thead with the page). overflow-auto is the safety net for the narrow end. `xl`, not `md`. Six columns need ~880px, but this table renders inside the app shell's max-w-5xl column *next to the sidebar*: measured content width is 476px at a 820px viewport and 632px at 1024px, so every width below ~1130px got a table that scrolled sideways. The card view below is a better answer for that range than a table you have to drag. Note the ceiling is max-w-5xl, not the viewport — content tops out at ~976px however wide the screen gets, so the column budget below has to fit in that. */}
{/* The only accessible name this table has: the visible "Library" heading lives in the server-rendered page, outside the island. */} {/* The row scrolling under the pinned header needs an edge to disappear behind, or it dissolves into the header fill. */} {sortedBooks.map((book) => ( updateBook_(book.hiveId, fields)} onDelete={() => requestDelete(book)} /> ))}
Your library
toggleSort("title")} > Book toggleSort("status")} > Status toggleSort("rating")} > Rating Progress toggleSort("date")} > Dates Actions
{/* Mobile: card view */}
{sortedBooks.map((book) => ( updateBook_(book.hiveId, fields)} onDelete={() => requestDelete(book)} /> ))}
); };