304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
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<typeof Loaded<LibraryData>>[0]['query'];
|
|
chapters: Parameters<typeof Loaded<ChapterList>>[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<Intl.LDMLPluralRule, MessageKey> = {
|
|
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 (
|
|
<Panel
|
|
label={text('library.panel')}
|
|
landmark="navigation"
|
|
selectedId="books"
|
|
onSelect={() => 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: (
|
|
<Loaded
|
|
query={library}
|
|
waiting={text('library.panel')}
|
|
isEmpty={(data) => data.books.length === 0}
|
|
empty={{
|
|
title: text('library.emptyTitle'),
|
|
description: text('library.emptyDescription'),
|
|
}}
|
|
>
|
|
{(data) => (
|
|
<Books
|
|
books={data.books}
|
|
chapters={chapters}
|
|
openBookId={openBookId}
|
|
selectedId={selectedId}
|
|
onOpen={onOpen}
|
|
onPin={onPin}
|
|
onPreload={onPreload}
|
|
/>
|
|
)}
|
|
</Loaded>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Books({
|
|
books,
|
|
chapters,
|
|
openBookId,
|
|
selectedId,
|
|
onOpen,
|
|
onPin,
|
|
onPreload,
|
|
}: Pick<Props, 'chapters' | 'openBookId' | 'selectedId' | 'onOpen' | 'onPin' | 'onPreload'> & {
|
|
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<Set<string> | 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
|
|
// (`<chapter>:<cut>:<index>`) — 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 (
|
|
<Tree
|
|
label={text('library.treeLabel')}
|
|
items={items}
|
|
selectedId={selectedId}
|
|
// A row of a GROUP is not a thing to open — it is a fold, and a click on it folds. Without
|
|
// this the row answered a click with nothing at all, which is what a row that is not a row
|
|
// looks like (found by the adversarial review).
|
|
onSelect={(id) => {
|
|
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: (
|
|
<span className={styles.bookIcon} data-tone={state.tone}>
|
|
<BookIcon {...icon} />
|
|
</span>
|
|
),
|
|
// 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: (
|
|
<span className={styles.badge} title={text(state.label)}>
|
|
{text(state.label)}
|
|
</span>
|
|
),
|
|
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: <FileText {...icon} className={styles.icon} />,
|
|
trailing: (
|
|
<>
|
|
{chapter.note_count > 0 && (
|
|
<span className={styles.noteCount}>
|
|
{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. */}
|
|
<span className={styles.hidden}> {counted(chapter.note_count, notes)}</span>
|
|
</span>
|
|
)}
|
|
{/* A chapter shows PROGRESS by units: it has no state of its own, the run has. */}
|
|
<span className={styles.progress} style={{ '--progress': done }}>
|
|
<span className={styles.progressFill} data-done={done >= 1 || undefined} />
|
|
<span className={styles.hidden}>
|
|
{text('library.chapterProgress', {
|
|
done: chapter.units_done,
|
|
total: chapter.units_total,
|
|
})}
|
|
</span>
|
|
</span>
|
|
</>
|
|
),
|
|
};
|
|
}
|