302 lines
12 KiB
TypeScript
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();
|
|
}
|