textmachine/frontend/src/showcase/AddBook.tsx

302 lines
12 KiB
TypeScript

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { FilePlus2 } from 'lucide-react';
import { useRef, useState } from 'react';
import { ApiError, UploadAborted, keys, uploadBook } from '../api';
import type { Book, BookIntake, UploadProgress } from '../api';
import { useText, type MessageKey, type Text } from '../i18n/text';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
import { ProgressBar } from '../ui/ProgressBar';
import { Select } from '../ui/Select';
import { TextField } from '../ui/TextField';
import { icon } from '../ui/icon';
import { fileSize, languageName, number, statusOf } from './format';
import { sourceLanguages, targetLanguages } from './languages';
import styles from './AddBook.module.css';
/**
* Adding a book: the form, the sending, and what came of it.
*
* The four states are one screen and not four, because they are one act of the user's: the window
* that took the file is the window that says what happened to it. A refusal is a state of this
* form — not an alert over it (§3.8: a screen that looks alarming is a wrong screen).
*/
export function AddBook({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const text = useText();
const client = useQueryClient();
const input = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [sourceLang, setSourceLang] = useState<string>(sourceLanguages[0]);
const [targetLang, setTargetLang] = useState<string>(targetLanguages[0]);
const [genre, setGenre] = useState('');
// `null` means the browser has said nothing about the body going out — which is a state and not
// an absence of one (see `Sending`).
const [sent, setSent] = useState<UploadProgress | null>(null);
// Cancelling is the user's, so the handle to it belongs to the sending and not to the request:
// the screen has to be able to stop a body that has minutes left to go.
const abort = useRef<AbortController | null>(null);
const upload = useMutation<Book, Error, BookIntake>({
mutationFn: (intake) => {
abort.current = new AbortController();
setSent(null);
return uploadBook(intake, {
signal: abort.current.signal,
onProgress: setSent,
});
},
onSuccess: () => {
// The library is the only place the new book can be seen from, and it is read by a query that
// considers itself fresh for fifteen seconds. Nothing else would ask it again.
void client.invalidateQueries({ queryKey: keys.library() });
},
});
const close = () => {
abort.current?.abort();
upload.reset();
setFile(null);
setTitle('');
setGenre('');
setSent(null);
onClose();
};
const send = () => {
if (file === null) return;
upload.mutate({ file, sourceLang, targetLang, title, genre });
};
const accepted = upload.data;
return (
<Modal
title={text('addBook.title')}
isOpen={isOpen}
onClose={close}
footer={
<>
<Button look="action" onPress={close}>
{text(accepted ? 'action.done' : 'action.cancel')}
</Button>
{accepted === undefined && (
<Button look="primary" isDisabled={file === null || upload.isPending} onPress={send}>
{text('addBook.submit')}
</Button>
)}
</>
}
>
{accepted ? (
// The same reason as the live region below: the form is gone and the answer is what
// replaced it, so it is announced rather than merely drawn.
<div role="status">
<Accepted book={accepted} />
</div>
) : (
<div className={styles.form}>
<div>
<p className={styles.label}>{text('addBook.fileLabel')}</p>
{/* A real file picker, not a drawn one: the file name is needed for real — the automatic
parse takes the title out of it, and without a real name that branch cannot be
checked by a frame. */}
<input
className={styles.hidden}
ref={input}
type="file"
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
setFile(event.target.files?.[0] ?? null);
upload.reset();
// The input keeps its value, and an input whose value did not change fires nothing:
// picking the SAME file again after a refusal would then be silent, and the refusal
// would stay on the screen under a file the person had just re-chosen.
event.target.value = '';
}}
/>
<Button
look="action"
isDisabled={upload.isPending}
onPress={() => input.current?.click()}
>
<FilePlus2 {...icon} />
{file === null ? text('addBook.chooseFile') : `${file.name} · ${fileSize(file.size)}`}
</Button>
</div>
<TextField
label={text('addBook.titleLabel')}
value={title}
onChange={setTitle}
placeholder={file === null ? text('addBook.titlePlaceholder') : parsedTitle(file.name)}
hint={title === '' ? text('addBook.titleHintAuto') : text('addBook.titleHintManual')}
/>
<div className={styles.pair}>
<Select
label={text('addBook.sourceLangLabel')}
options={sourceLanguages.map((code) => ({ id: code, label: languageName(code) }))}
value={sourceLang}
onChange={setSourceLang}
/>
<Select
label={text('addBook.targetLangLabel')}
options={targetLanguages.map((code) => ({ id: code, label: languageName(code) }))}
value={targetLang}
onChange={setTargetLang}
/>
</div>
<TextField
label={text('addBook.genreLabel')}
value={genre}
onChange={setGenre}
placeholder={text('addBook.genrePlaceholder')}
/>
{/* ⚠ A live region, and it is PERMANENT rather than mounted with its content: what
happens after the button is pressed happens away from the focus — the sending, and
then either the answer or the refusal — and a reader that is not told is a reader
who thinks nothing happened (WCAG 4.1.3). A region inserted together with its text
is announced by some readers and not by others; one that was already there is
announced by all. */}
<div role="status" className={styles.live}>
{upload.isPending && <Sending file={file} sent={sent} />}
{upload.isError && <Failed error={upload.error} />}
</div>
</div>
)}
</Modal>
);
}
/**
* How far the file has got — in three states, and the first of them is the one that is easy to get
* wrong.
*
* ⚠ **Nothing said yet.** The browser reports the progress of a request it sends itself; a request
* answered by a service worker it does not — measured under the mock network: `loadstart` arrives
* with the right total and not a single `progress` after it. There is then no share to draw, and a
* bar sitting at zero for the whole upload would be a number that means nothing, so what is shown
* is the size going out and no percentage at all.
*
* **Going.** A share, and it is the browser's own numbers; the size of the FILE stands in when the
* browser states no total of its own — the multipart envelope is tens of bytes against tens of
* megabytes.
*
* **All out.** The last byte leaving is not the answer: the service is still writing the book's row
* and cutting nothing yet. Without that line a bar frozen at a hundred per cent reads as a hang.
*/
function Sending({ file, sent }: { file: File | null; sent: UploadProgress | null }) {
const text = useText();
const size = file?.size ?? 0;
if (sent === null) {
return (
<div className={styles.sending}>
<p className={styles.slot}>{text('addBook.sendingUnknown', { total: fileSize(size) })}</p>
</div>
);
}
const total = sent.total ?? size;
const share = total === 0 ? 0 : Math.min(1, sent.sent / total);
return (
<div className={styles.sending}>
<ProgressBar
label={text('addBook.sending')}
value={share}
valueLabel={text('addBook.sendingSize', {
sent: fileSize(sent.sent),
total: fileSize(total),
})}
/>
{share >= 1 && <p className={styles.slot}>{text('addBook.sent')}</p>}
</div>
);
}
/**
* What came back, in the words of the contract and no others: the platform answers with the book
* card itself, so the confirmation is the same four fields the panel of the book shows — and
* `chapter_count` is deliberately not among them. It is zero until the parse ends, and printing a
* zero would answer "how many sections" with a lie.
*/
function Accepted({ book }: { book: Book }) {
const text = useText();
const fields: [string, string][] = [
[text('about.title'), book.title],
[
text('about.languages'),
`${languageName(book.source_lang)}${languageName(book.target_lang)}`,
],
// A dash and not a zero when the platform sent no size: the field is optional on the wire, and
// "0 characters" is a number the book does not have.
[
text('about.characters'),
book.character_count === undefined ? '—' : number(book.character_count),
],
[text('about.status'), text(statusOf(book.status).label)],
];
return (
<div className={styles.accepted}>
<h3 className={styles.acceptedTitle}>{text('addBook.acceptedTitle')}</h3>
<dl className={styles.fields}>
{fields.map(([name, value]) => (
<div className={styles.field} key={name}>
<dt className={styles.fieldName}>{name}</dt>
<dd className={styles.fieldValue}>{value}</dd>
</div>
))}
</dl>
<p className={styles.slot}>{text('addBook.acceptedDescription')}</p>
</div>
);
}
/**
* A refusal of the intake, as a state of the form.
*
* The PHRASE is the platform's when it sent one: `problem+json` carries product language on both
* fields by contract, and a second copy of it here would be a second thing to keep in step. What is
* ours is the ADVICE — the next action, which the wire does not carry and which is different for
* every class: check the file, send it again, or wait.
*/
function Failed({ error }: { error: Error }) {
const text = useText();
const [phrase, advice] = refusal(error, text);
return (
<div className={styles.failed}>
<p className={styles.failedTitle}>{phrase}</p>
<p className={styles.slot}>{text(advice)}</p>
</div>
);
}
function refusal(error: Error, text: Text): [string, MessageKey] {
if (error instanceof UploadAborted) return [text('upload.failedTitle'), 'upload.retryAdvice'];
if (!(error instanceof ApiError)) return [text('loaded.unknownError'), 'upload.refusedAdvice'];
if (error.status === 0) return [text('loaded.serverUnreachable'), 'upload.retryAdvice'];
const own: Partial<Record<number, [MessageKey, MessageKey]>> = {
400: ['upload.badRequest', 'upload.badRequestAdvice'],
404: ['upload.notAccepted', 'upload.notAcceptedAdvice'],
408: ['upload.timeout', 'upload.retryAdvice'],
413: ['upload.tooLarge', 'upload.tooLargeAdvice'],
};
const [fallback, advice] = own[error.status] ?? ['upload.refused', 'upload.refusedAdvice'];
return [error.problem?.detail ?? error.problem?.title ?? text(fallback), advice];
}
/**
* What the book will be called if the field is left empty — that very second source signed in the
* form. A PREDICTION of the platform's own `titleFrom`, and it has to be an accurate one: the
* platform strips the extension and nothing else (books.go), so a client that also turned
* underscores into spaces showed «gu zhen ren» and got back `gu-zhen-ren` (found by the adversarial
* review, which checked it against the platform's own test). Deciding the title is not this
* function's business — saying what to expect is.
*/
function parsedTitle(file: string): string {
return file.replace(/\.[^.]+$/, '').trim();
}