textmachine/frontend/src/showcase/Loaded.tsx

69 lines
3 KiB
TypeScript

import type { ReactNode } from 'react';
import { ApiError } from '../api';
import { useText, type Text } from '../i18n/text';
import { Blank } from './Blank';
export interface Query<T> {
isPending: boolean;
isError: boolean;
/** `idle` means the query is switched off — pending, but nothing is on its way. */
fetchStatus: 'fetching' | 'paused' | 'idle';
error: Error | null;
data: T | undefined;
}
interface Props<T> {
query: Query<T>;
/** What the place is called while it waits — "loading" alone says nothing about what. */
waiting: string;
/** True when the answer arrived and there is nothing in it. */
isEmpty?: (data: T) => boolean;
empty?: { title: string; description: string };
/** What a switched-off query means HERE. "Pick a book" is wrong when there is none to pick. */
idle?: { title: string; description: string };
children: (data: T) => ReactNode;
}
/**
* The three branches every read has, in one place. They arrive together with the data layer rather
* than after the first product screen — a screen written against data that is always there grows a
* shape that has nowhere to put "not yet" and "did not work" (BACKLOG Ф-15).
*/
export function Loaded<T>({ query, waiting, isEmpty, empty, idle, children }: Props<T>) {
const text = useText();
// A switched-off query is PENDING forever, and drawing that as "loading" is a lie the screenshot
// cycle caught: an empty library showed a bank eternally on its way.
const nothingChosen = {
title: text('loaded.noBookTitle'),
description: text('loaded.noBookDescription'),
};
if (query.isPending) {
if (query.fetchStatus === 'idle') return <Blank {...(idle ?? nothingChosen)} />;
return <Blank title={waiting} description={text('blank.loading')} waiting />;
}
// A failed refetch does NOT blank a panel that already has something in it. The query layer keeps
// the previous data alongside the error, and replacing a rendered chapter with "could not load"
// because a background refresh failed loses the user's place for no reason.
if (query.data === undefined) {
return <Blank title={text('loaded.failedTitle')} description={reasonOf(query.error, text)} />;
}
if (empty && isEmpty?.(query.data)) {
return <Blank title={empty.title} description={empty.description} />;
}
return <>{children(query.data)}</>;
}
/**
* The phrase the user reads on a failure. Taken from `problem+json`, which the contract requires to
* be product language on BOTH fields — the engine's own wording ("CJK leak in the ru output: 第一节")
* never crosses the boundary, and this is the place that would put it on screen if it did.
* `detail` first, because it is the specific sentence; `title` is the class of the failure.
*/
function reasonOf(error: Error | null, text: Text): string {
if (error instanceof ApiError) {
if (error.status === 0) return text('loaded.serverUnreachable');
return error.problem?.detail ?? error.problem?.title ?? text('loaded.serverRefused');
}
return text('loaded.unknownError');
}