119 lines
5.1 KiB
TypeScript
119 lines
5.1 KiB
TypeScript
import { pausedReason as pausedReasonWords, rejectReason as rejectReasonWords } from '../api';
|
||
import type { Book, BookDetail, Run } from '../api';
|
||
import { useText, type Text } from '../i18n/text';
|
||
import { Button } from '../ui/Button';
|
||
import { Callout } from '../ui/Callout';
|
||
import { counted, date, languageName, number, statusOf, translatedPercent } from './format';
|
||
import { chapters } from './units';
|
||
import styles from './About.module.css';
|
||
|
||
/**
|
||
* Metadata of the book (remark 6), extended exactly up to the boundary of the contract. The
|
||
* boundary is a defence of the approaches, not a gap: there is no money on the screen (§4.8), and
|
||
* no models or pipeline stages either.
|
||
*/
|
||
export function About({ detail, onStartRun }: { detail: BookDetail; onStartRun?: () => void }) {
|
||
const text = useText();
|
||
const { book, run } = detail;
|
||
const startable = statusOf(book.status).startable === true;
|
||
|
||
return (
|
||
<div className={styles.card}>
|
||
<State book={book} run={run ?? null} />
|
||
{/* The one action of the book card, and it stands where the card is: a book is translated
|
||
from the place that says what the book is. It is offered only where a run can begin —
|
||
which state that is, is decided by the vocabulary on the api seam, in one file. */}
|
||
{startable && onStartRun !== undefined && (
|
||
<Button look="primary" onPress={onStartRun}>
|
||
{text('run.action')}
|
||
</Button>
|
||
)}
|
||
<dl className={styles.about}>
|
||
{fields(detail, text).map(([name, value]) => (
|
||
<div className={styles.field} key={name}>
|
||
<dt className={styles.fieldName}>{name}</dt>
|
||
<dd className={styles.fieldValue} title={value}>
|
||
{value}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The states a book is IN rather than the fields it has: still arriving, being cut into sections,
|
||
* refused, halted. Each of them is a state the list of pairs below cannot say anything about, and
|
||
* each of them is what the person is waiting on.
|
||
*
|
||
* ⚠ Parsing carries NO number, and that is the contract's shape and not an omission: there is no
|
||
* counter of it anywhere on this surface, so a percentage would have to be invented (0.2.3).
|
||
*/
|
||
function State({ book, run }: { book: Book; run: Run | null }) {
|
||
const text = useText();
|
||
|
||
if (book.status === 'uploading')
|
||
return <Callout tone="quiet" caption={text('about.uploading')} />;
|
||
if (book.status === 'parsing') return <Callout tone="quiet" caption={text('about.parsing')} />;
|
||
if (book.status === 'rejected') {
|
||
// The machine reason is the platform's, the phrase and the advice are the product's — and the
|
||
// advice is the point: "the file is not one we can read" and "not us, not now" end in
|
||
// different next actions (0.2.3, PD-173).
|
||
const reason = rejectReasonWords.describe(book.reject_reason ?? null);
|
||
return (
|
||
<Callout tone="note" caption={text(reason.label)}>
|
||
{text(reason.advice)}
|
||
</Callout>
|
||
);
|
||
}
|
||
// A halt is stated where the action is, not only in the status strip: the strip says WHAT, the
|
||
// card is where the person looks for what to do about it. The phrase is the owner's (В-6), and
|
||
// resume is deliberately not offered — after a ceiling stop it does not move the run at all.
|
||
if (book.status === 'paused') {
|
||
return (
|
||
<Callout
|
||
tone="note"
|
||
caption={text(pausedReasonWords.describe(run?.paused_reason ?? null).label)}
|
||
/>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function fields(detail: BookDetail, text: Text): [string, string][] {
|
||
const { book, run } = detail;
|
||
const rows: [string, string][] = [
|
||
[text('about.title'), book.title],
|
||
[
|
||
text('about.languages'),
|
||
`${languageName(book.source_lang)} → ${languageName(book.target_lang)}`,
|
||
],
|
||
[text('about.genre'), book.genre ?? '—'],
|
||
[text('about.status'), text(statusOf(book.status).label)],
|
||
[text('about.translated'), `${String(translatedPercent(book.progress))}%`],
|
||
[text('about.chapters'), number(book.chapter_count)],
|
||
[text('about.blocks'), number(book.progress.draft.total)],
|
||
// Optional on the wire, so an absent size is a dash rather than a zero — the same shape the
|
||
// dates use for "not known".
|
||
[
|
||
text('about.characters'),
|
||
book.character_count === undefined ? '—' : number(book.character_count),
|
||
],
|
||
[text('about.notes'), number(book.note_count)],
|
||
[text('about.added'), date(book.added_at)],
|
||
];
|
||
if (run) {
|
||
rows.push(
|
||
[text('about.runStarted'), date(run.started_at)],
|
||
// `finished_at` comes both as `null` and as altogether absent: an unfinished run may have no
|
||
// field at all — both forms mean one and the same thing and are drawn as a dash.
|
||
[text('about.runFinished'), run.finished_at ? date(run.finished_at) : '—'],
|
||
// The ceiling and the stop at the signature are the person's own choice before the start,
|
||
// not the workings of the engine: showing them is honest and useful, he set them himself.
|
||
[text('about.ceiling'), counted(run.ceiling_chapters, chapters)],
|
||
[text('about.verifyBank'), run.verify_bank ? text('about.yes') : text('about.no')],
|
||
);
|
||
}
|
||
return rows;
|
||
}
|