import { BookIcon, FileText } from 'lucide-react'; import { useMemo, useState } from 'react'; import type { Book, Chapter, ChapterList, Library as LibraryData } from '../api'; import { useText, type MessageKey, type Text } from '../i18n/text'; import { Panel } from '../shell/Panel'; import { icon } from '../ui/icon'; import { Tree, type TreeNode } from '../ui/Tree'; import { Loaded } from './Loaded'; import { chapterLabel, counted, number, statusOf } from './format'; import styles from './Library.module.css'; interface Props { library: Parameters>[0]['query']; chapters: Parameters>[0]['query']; openBookId?: string; selectedId?: string; /** A single click is a preview, a double one pins it (the VS Code model, remark 2). */ onOpen: (id: string) => void; onPin: (id: string) => void; /** The row is about to open — read its chapter now, before the click completes. */ onPreload: (id: string) => void; onAddBook: () => void; } /** Plural forms of the note counter; Intl picks the category. */ const notes: Record = { zero: 'library.notesZero', one: 'library.notesOne', two: 'library.notesTwo', few: 'library.notesFew', many: 'library.notesMany', other: 'library.notesOther', }; export function Library({ library, chapters, openBookId, selectedId, onOpen, onPin, onPreload, onAddBook, }: Props) { const text = useText(); return ( undefined} // There is only one tab here so far, but keep-alive is a property of the PANEL: bring back a // second one (search within the book, Ф-33) — and the scroll of a tree of 2284 nodes will not // be lost together with it. keepAlive // Ф-7: "+" stands where it has a real action. Our tab sets are fixed and there is nothing to // add to them — but a book is added to the library from here. add={{ label: text('library.addBook'), onPress: onAddBook }} tabs={[ { id: 'books', label: text('library.booksTab'), content: ( data.books.length === 0} empty={{ title: text('library.emptyTitle'), description: text('library.emptyDescription'), }} > {(data) => ( )} ), }, ]} /> ); } function Books({ books, chapters, openBookId, selectedId, onOpen, onPin, onPreload, }: Pick & { books: Book[]; }) { const text = useText(); // The chapters of the open book only: a tree that fetched every book's chapters would read the // whole library to draw one row. const rows = useMemo(() => chapters.data?.chapters ?? [], [chapters.data]); // `null` means "the user has not touched the branches yet" — not "everything is closed". The same // idiom the tab row uses for its own first look. const [expanded, setExpanded] = useState | null>(null); const opened = expanded ?? new Set(openBookId === undefined ? [] : [openBookId]); // Two branches open by themselves, and both only ON A CHANGE — added on every render they could // never be folded again. A chapter chosen from OUTSIDE the tree (the go-to palette, a row of the // notes summary) may sit inside a folded group, and a tree that answered a jump by showing // nothing would be a tree that lost the row; and a book just chosen shows what is in it. const [seen, setSeen] = useState({ selectedId, openBookId }); if (seen.selectedId !== selectedId || seen.openBookId !== openBookId) { setSeen({ selectedId, openBookId }); // ⚠ The BOOK goes with the group, always: a jump from outside the tree into a folded book // opened the group inside it and left the book shut, so the row it jumped to was still not on // the screen (found by the adversarial review, reproduced by rendering). const opening = [groupOf(rows, openBookId, selectedId), openBookId].filter( (id): id is string => id !== undefined && id !== null && !opened.has(id), ); if (opening.length > 0) setExpanded(new Set([...opened, ...opening])); } // A book whose chapters did not arrive must not be drawn as a book WITHOUT chapters: no chevron // reads as "this book has none", while the card next to it says 2284. The state goes where the // chapters would have been. const pending = chapters.isPending && chapters.fetchStatus !== 'idle'; const state = pending ? text('library.chaptersLoading') : chapters.isError ? text('library.chaptersFailed') : null; // The state belongs to the OPEN book only: it is the only one whose chapters were asked for, and // giving every row the same child promised sections for books nobody has opened. const items = useMemo( () => books.map((book) => book.id === openBookId ? bookNode(book, rows, state, text) : bookNode(book, [], null, text), ), [books, rows, openBookId, state, text], ); // ⚠ The ids of the folds, kept as a SET rather than recognised by their shape. A row's kind is // decided by what this file MINTED, never by reading a server id: ids are opaque by contract, and // the engine already builds keys of the very shape a pattern would have matched // (`::`) — a chapter whose id ended that way would have answered a click by // folding a branch that does not exist (found by the adversarial review). const groups = useMemo( () => new Set( items.flatMap((node) => (node.children ?? []) // A fold is a child that HAS children of its own. Below the threshold the tree is flat // and a book's children are the chapters themselves — taking every child would have // made every chapter a fold, and a click on one would have folded instead of opening // (caught by the scene battery, not by reasoning). .filter((child) => child.children !== undefined) .map((child) => child.id), ), ), [items], ); return ( { if (!groups.has(id)) { onOpen(id); return; } const next = new Set(opened); if (!next.delete(id)) next.add(id); setExpanded(next); }} onActivate={onPin} onPreload={onPreload} expandedIds={opened} onExpandedChange={setExpanded} /> ); } function bookNode( book: Book, chapters: Chapter[], chaptersState: string | null, text: Text, ): TreeNode { // The state is a WORD, not a dot, and the unknown value has its own neutral word rather than // crashing the tree on `undefined.tone` — the map and its unknown branch live on the api seam. const state = statusOf(book.status); return { id: book.id, title: book.title, // The state tone lives ON the book glyph, not on a dot beside the title (remark 21). icon: ( ), // The badge stands in ITS OWN place of fixed width and the title is truncated: there is now // nothing left for them to overlap with (remark 23). trailing: ( {text(state.label)} ), children: childrenOf(book, chapters, chaptersState, text), }; } /** * How many chapters are put into one group, and from how many the tree starts grouping at all. * * Hundreds, and the number is not a taste: a chapter is addressed by its ORDINAL, and a hundred is * the step a person counts them in. The measurement says the same thing from the other side — a * flat tree of 2284 rows costs nothing to draw (the virtualizer keeps 31 of them in the DOM, * BACKLOG Ф-12), so grouping is not bought for the frame rate; it is bought for the scrollbar, on * which one row of 2284 is a third of a pixel and 59 618px of travel separates chapter 1 from * chapter 2284. Twenty-three rows and one fold do the same journey. * * Below the threshold the tree stays FLAT: a book of a dozen chapters gains nothing from a fold * that has to be opened before the book can be read at all. */ const groupSize = 100; const groupFrom = 200; /** The id of a fold. Minted here and recognised by membership, never by its shape (see `groups`). */ const groupId = (bookId: string, from: number) => `${bookId}:${String(from)}`; /** The group a chapter sits in, or `null` when the tree is flat or the id is not a chapter. */ function groupOf(chapters: Chapter[], bookId: string | undefined, chapterId: string | undefined) { if (chapters.length <= groupFrom || bookId === undefined || chapterId === undefined) return null; const position = chapters.findIndex((chapter) => chapter.id === chapterId); if (position < 0) return null; return groupId(bookId, position - (position % groupSize)); } // One row saying what happened, in the place the sections would occupy. Not selectable: the screen // only opens ids it found among the chapters. function childrenOf( book: Book, chapters: Chapter[], chaptersState: string | null, text: Text, ): TreeNode[] | undefined { if (chaptersState !== null) return [{ id: `${book.id}:chapters-state`, title: chaptersState }]; if (chapters.length === 0) return undefined; if (chapters.length <= groupFrom) { return chapters.map((chapter, index) => chapterNode(chapter, index, text)); } return Array.from({ length: Math.ceil(chapters.length / groupSize) }, (_, group) => { const from = group * groupSize; const inside = chapters.slice(from, from + groupSize); return { id: groupId(book.id, from), // The range is of POSITIONS in reading order and not of the numbers printed on the chapters: // a book legally has no numbering at all, and half of one is legal too (K-3). title: text('library.chapterRange', { from: from + 1, to: from + inside.length }), children: inside.map((chapter, index) => chapterNode(chapter, from + index, text)), }; }); } function chapterNode(chapter: Chapter, index: number, text: Text): TreeNode { // Computed before the attribute: the gate allows a CSS variable to carry a number or a token, // and forbids the carriers of a literal — a ternary among them. const done = chapter.units_total === 0 ? 0 : chapter.units_done / chapter.units_total; return { id: chapter.id, title: chapterLabel(chapter, index + 1), icon: , trailing: ( <> {chapter.note_count > 0 && ( {number(chapter.note_count)} {/* The word is DECLINED, not nailed to one form: this line is read aloud, and a fixed genitive says "1 of-notes" on every chapter that has a single note. */} {counted(chapter.note_count, notes)} )} {/* A chapter shows PROGRESS by units: it has no state of its own, the run has. */} = 1 || undefined} /> {text('library.chapterProgress', { done: chapter.units_done, total: chapter.units_total, })} ), }; }