69 lines
3 KiB
TypeScript
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');
|
|
}
|