textmachine/frontend/src/showcase/Library.tsx

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>
</>
),
};
}