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(null); const [file, setFile] = useState(null); const [title, setTitle] = useState(''); const [sourceLang, setSourceLang] = useState(sourceLanguages[0]); const [targetLang, setTargetLang] = useState(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(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(null); const upload = useMutation({ 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 ( {accepted === undefined && ( )} } > {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.
) : (

{text('addBook.fileLabel')}

{/* 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. */} { 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 = ''; }} />
({ id: code, label: languageName(code) }))} value={targetLang} onChange={setTargetLang} />
{/* ⚠ 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. */}
{upload.isPending && } {upload.isError && }
)}
); } /** * 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 (

{text('addBook.sendingUnknown', { total: fileSize(size) })}

); } const total = sent.total ?? size; const share = total === 0 ? 0 : Math.min(1, sent.sent / total); return (
{share >= 1 &&

{text('addBook.sent')}

}
); } /** * 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 (

{text('addBook.acceptedTitle')}

{fields.map(([name, value]) => (
{name}
{value}
))}

{text('addBook.acceptedDescription')}

); } /** * 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 (

{phrase}

{text(advice)}

); } 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> = { 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(); }