textmachine/frontend/src/showcase/Showcase.tsx

277 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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