277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
import { useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { Search, Settings as SettingsIcon } from 'lucide-react';
|
||
import { useMemo, useState } from 'react';
|
||
|
||
import { bankQuery, bookQuery, chaptersQuery, libraryQuery, notesQuery, unitsQuery } from '../api';
|
||
import { useText, type Text } from '../i18n/text';
|
||
import { Panel } from '../shell/Panel';
|
||
import { useLayout } from '../shell/layout';
|
||
import { Shell } from '../shell/Shell';
|
||
import { Button } from '../ui/Button';
|
||
import { icon } from '../ui/icon';
|
||
import type { TabItem } from '../ui/Tabs';
|
||
import { AddBook } from './AddBook';
|
||
import { Blank } from './Blank';
|
||
import { Context } from './Context';
|
||
import { Document } from './Document';
|
||
import { Goto } from './Goto';
|
||
import { Library } from './Library';
|
||
import type { Query } from './Loaded';
|
||
import { RunStart } from './RunStart';
|
||
import { Settings } from './Settings';
|
||
import { Status } from './Status';
|
||
import {
|
||
closeDocument,
|
||
firstLook,
|
||
openedIds,
|
||
pinDocument,
|
||
previewDocument,
|
||
selectDocument,
|
||
} from './documents';
|
||
import { chapterLabel, isDraft } from './format';
|
||
import { useIntakeEnd } from './useIntakeEnd';
|
||
import { useRunStream } from './useRunStream';
|
||
import { useScreenshotFlag } from './useScreenshotFlag';
|
||
|
||
type Overlay = 'settings' | 'add-book' | 'goto' | 'run' | null;
|
||
|
||
/**
|
||
* The showcase, now on the data layer. One screen for every scenario: what changes between routes
|
||
* is only what the network answers, which is the point — the screen does not know which world it
|
||
* is in, and could not be written to know.
|
||
*/
|
||
export function Showcase() {
|
||
const text = useText();
|
||
const library = useQuery(libraryQuery());
|
||
// Which book is open: the one chosen in the tree, and the first the platform returned until
|
||
// something is chosen (the zone's §2 — "a click on a book opens its state"). Checked against the
|
||
// library on every render rather than reset by an effect: a chosen book can DISAPPEAR from the
|
||
// library, and a screen holding an id that is no longer there reads every panel as a failure.
|
||
const [chosen, setChosen] = useState<string | null>(null);
|
||
const books = library.data?.books;
|
||
const bookId =
|
||
chosen !== null && books?.some((row) => row.id === chosen) === true ? chosen : books?.[0]?.id;
|
||
const enabled = bookId !== undefined;
|
||
|
||
const book = useQuery({ ...bookQuery(bookId ?? ''), enabled });
|
||
const chapters = useQuery({ ...chaptersQuery(bookId ?? ''), enabled });
|
||
const notes = useQuery({ ...notesQuery(bookId ?? ''), enabled });
|
||
const bank = useQuery({ ...bankQuery(bookId ?? ''), enabled });
|
||
|
||
// Three different reasons a dependent panel has nothing to show, and they must not be conflated.
|
||
// WAITING: the library has not answered yet, so its dependants are switched off only because
|
||
// their prerequisite is late. FAILED: the library refused — a panel that says "no book chosen"
|
||
// then is lying about a failure, which was on the session's own error shot. Otherwise: pass
|
||
// through, and the panel's own state decides.
|
||
const pendingLibrary = library.isPending && library.fetchStatus !== 'idle';
|
||
const failedLibrary = library.isError && library.data === undefined;
|
||
const awaiting = <T,>(query: Query<T>): Query<T> => {
|
||
if (pendingLibrary) return { ...query, fetchStatus: 'fetching' };
|
||
if (failedLibrary) return { ...query, isPending: false, isError: true, error: library.error };
|
||
return query;
|
||
};
|
||
|
||
const connection = useRunStream(bookId, book.data?.run?.id);
|
||
useIntakeEnd(bookId, book.data?.book.status);
|
||
const expandPanel = useLayout((state) => state.expand);
|
||
|
||
const [overlay, setOverlay] = useState<Overlay>(null);
|
||
const [contextTab, setContextTab] = useState('bank');
|
||
// Not a search string, but the palette's REQUEST to show a term: the string itself is the bank's
|
||
// own. With a number, because a request to show the SAME term a second time is a request too, and
|
||
// React recomputes nothing for an equal value.
|
||
const [bankRequest, setBankRequest] = useState({ term: '', seq: 0 });
|
||
// `null` means "the user has not touched the tabs yet", which is not the same as "no tabs open".
|
||
// Kept as a derived default rather than seeded by an effect: writing state from an effect makes
|
||
// the first frame render twice, and the chapters arrive asynchronously, so it would render twice
|
||
// on every load.
|
||
const [opened, setOpened] = useState<ReturnType<typeof firstLook> | null>(null);
|
||
|
||
const rows = useMemo(() => chapters.data?.chapters ?? [], [chapters.data]);
|
||
const documents = opened ?? firstLook(rows.map((chapter) => chapter.id));
|
||
|
||
const positionOf = (id: string) => rows.findIndex((chapter) => chapter.id === id);
|
||
// The guard stands HERE and not at every caller: the tabs are built only out of chapters that
|
||
// exist, so a selected id that is not among the chapters leaves the controlled tab row with no
|
||
// selected tab — an empty centre without a single word (the notes summary did exactly that).
|
||
//
|
||
// A row of the tree is a chapter OR a book, and the two are told apart by where the id is found.
|
||
// Choosing a book closes the open documents: they are chapters of the book being left, and
|
||
// keeping them would leave tabs pointing into a tree that no longer holds them.
|
||
const open = (id: string) => {
|
||
if (positionOf(id) >= 0) {
|
||
setOpened(previewDocument(documents, id));
|
||
return;
|
||
}
|
||
if (books?.some((row) => row.id === id) === true && id !== bookId) {
|
||
setChosen(id);
|
||
setOpened(null);
|
||
}
|
||
};
|
||
const pin = (id: string) => {
|
||
if (positionOf(id) >= 0) setOpened(pinDocument(documents, id));
|
||
};
|
||
// Read the chapter while the button is still down, so the click lands on an answer that is
|
||
// already here. Measured: cold, the reader shows a "loading" card for 130–230 ms before the
|
||
// text; warm, it never appears at all. The query layer drops a prefetch of something it already
|
||
// holds fresh, so pressing the same row twice costs one read, not two.
|
||
const client = useQueryClient();
|
||
const preload = (id: string) => {
|
||
if (bookId === undefined || positionOf(id) < 0) return;
|
||
void client.prefetchQuery(unitsQuery(bookId, id));
|
||
};
|
||
const titleOf = (id: string) => {
|
||
const index = positionOf(id);
|
||
const chapter = rows[index];
|
||
return chapter ? chapterLabel(chapter, index + 1) : '';
|
||
};
|
||
|
||
const draft = isDraft(book.data?.book.status);
|
||
const tabs: TabItem[] = openedIds(documents)
|
||
.filter((id) => positionOf(id) >= 0)
|
||
.map((id) => ({
|
||
id,
|
||
label: titleOf(id),
|
||
preview: documents.preview === id,
|
||
onClose: () => setOpened(closeDocument(documents, id)),
|
||
content: (
|
||
<Document
|
||
bookId={bookId ?? ''}
|
||
chapterId={id}
|
||
sourceLang={book.data?.book.source_lang}
|
||
targetLang={book.data?.book.target_lang}
|
||
draft={draft}
|
||
onEngage={() => pin(id)}
|
||
/>
|
||
),
|
||
}));
|
||
|
||
return (
|
||
<>
|
||
<ScreenshotFlag />
|
||
<Shell
|
||
title={book.data?.book.title ?? text('shell.appName')}
|
||
actions={
|
||
<>
|
||
<Button
|
||
look="icon"
|
||
aria-label={text('shell.gotoAction')}
|
||
onPress={() => setOverlay('goto')}
|
||
>
|
||
<Search {...icon} />
|
||
</Button>
|
||
{/* The only way into the settings, and it is in the top right corner, as in Fleet
|
||
(remarks 11 and 19); the way in from the bottom of the left panel is gone. */}
|
||
<Button
|
||
look="icon"
|
||
aria-label={text('shell.settingsAction')}
|
||
onPress={() => setOverlay('settings')}
|
||
>
|
||
<SettingsIcon {...icon} />
|
||
</Button>
|
||
</>
|
||
}
|
||
left={
|
||
<Library
|
||
library={library}
|
||
chapters={awaiting(chapters)}
|
||
openBookId={bookId}
|
||
// The open CHAPTER while there is one, and the open book otherwise: a book whose
|
||
// sections nobody has opened is still the book the panels are showing.
|
||
selectedId={documents.active === '' ? bookId : documents.active}
|
||
onOpen={open}
|
||
onPin={pin}
|
||
onPreload={preload}
|
||
onAddBook={() => setOverlay('add-book')}
|
||
/>
|
||
}
|
||
center={
|
||
<Panel
|
||
label={text('shell.documentsPanel')}
|
||
landmark="main"
|
||
look="document"
|
||
tabs={tabs}
|
||
selectedId={documents.active}
|
||
onSelect={(id) => setOpened(selectDocument(documents, id))}
|
||
onPin={pin}
|
||
keepAlive
|
||
empty={<Blank {...emptyCentre(text, pendingLibrary, failedLibrary, enabled)} />}
|
||
/>
|
||
}
|
||
right={
|
||
<Context
|
||
book={awaiting(book)}
|
||
chapters={rows}
|
||
libraryEmpty={!enabled && !pendingLibrary && !failedLibrary}
|
||
notes={awaiting(notes)}
|
||
bank={awaiting(bank)}
|
||
tab={contextTab}
|
||
onTabChange={setContextTab}
|
||
request={bankRequest}
|
||
onOpenChapter={open}
|
||
onStartRun={enabled ? () => setOverlay('run') : undefined}
|
||
/>
|
||
}
|
||
status={
|
||
<Status
|
||
book={book.data?.book}
|
||
run={book.data?.run ?? null}
|
||
connection={connection}
|
||
chapter={titleOf(documents.active)}
|
||
/>
|
||
}
|
||
/>
|
||
<Settings isOpen={overlay === 'settings'} onClose={() => setOverlay(null)} />
|
||
<AddBook isOpen={overlay === 'add-book'} onClose={() => setOverlay(null)} />
|
||
{bookId !== undefined && (
|
||
<RunStart bookId={bookId} isOpen={overlay === 'run'} onClose={() => setOverlay(null)} />
|
||
)}
|
||
<Goto
|
||
isOpen={overlay === 'goto'}
|
||
onClose={() => setOverlay(null)}
|
||
chapters={rows}
|
||
terms={bank.data?.terms ?? []}
|
||
onOpenChapter={open}
|
||
onOpenTerm={(src) => {
|
||
// The action leads INTO the right panel, which means it is obliged to show it: on a
|
||
// collapsed panel it would silently switch a tab that nobody sees.
|
||
expandPanel('right');
|
||
setContextTab('bank');
|
||
setBankRequest((current) => ({ term: src, seq: current.seq + 1 }));
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Readiness of the screen for the screenshot cycle. A separate leaf rather than a line in
|
||
* `Showcase`: `useIsFetching` re-renders ITS OWN host on every start and finish of any request, and
|
||
* the host was the whole screen — including the tab row and the tree of 2284 rows. "In flight", not
|
||
* "pending": a switched-off query stays pending forever and would hold the flag at `loading` on a
|
||
* screen that settled long ago.
|
||
*/
|
||
function ScreenshotFlag() {
|
||
useScreenshotFlag(useIsFetching() > 0);
|
||
return null;
|
||
}
|
||
|
||
// Two different emptinesses, and telling the user to pick a chapter when there is no book to pick
|
||
// one from is the kind of wrong that reads as a broken screen.
|
||
function emptyCentre(text: Text, pending: boolean, failed: boolean, hasBook: boolean) {
|
||
if (pending) return { title: text('library.panel'), description: text('blank.loading') };
|
||
if (failed)
|
||
return {
|
||
title: text('library.failedTitle'),
|
||
description: text('library.failedDescription'),
|
||
};
|
||
if (hasBook)
|
||
return {
|
||
title: text('document.nothingOpenTitle'),
|
||
description: text('document.nothingOpenDescription'),
|
||
};
|
||
return {
|
||
title: text('library.emptyCentreTitle'),
|
||
description: text('library.emptyCentreDescription'),
|
||
};
|
||
}
|