266 lines
9.0 KiB
TypeScript
266 lines
9.0 KiB
TypeScript
import { useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
useGetDocument,
|
|
useEditDocument,
|
|
getGetDocumentQueryKey,
|
|
} from '../generated/anthoLumeAPIV1';
|
|
import type { EditDocumentBody } from '../generated/model';
|
|
import { formatDuration } from '../utils/formatters';
|
|
import { useToastMutation } from '../hooks/useMutationWithToast';
|
|
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
|
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
|
|
|
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
|
|
|
interface EditableFieldProps {
|
|
label: string;
|
|
value: string;
|
|
multiline?: boolean;
|
|
valueClassName?: string;
|
|
onSave: (value: string) => Promise<boolean>;
|
|
}
|
|
|
|
function EditableField({
|
|
label,
|
|
value,
|
|
multiline = false,
|
|
valueClassName,
|
|
onSave,
|
|
}: EditableFieldProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [draft, setDraft] = useState(value);
|
|
|
|
const startEdit = () => {
|
|
setDraft(value);
|
|
setIsEditing(true);
|
|
};
|
|
|
|
// Keep Editor Open On Failure - Only close once the save actually succeeds so a failed edit isn't silently lost.
|
|
const confirm = async () => {
|
|
if (await onSave(draft)) setIsEditing(false);
|
|
};
|
|
|
|
return (
|
|
<Field
|
|
label={
|
|
<>
|
|
<FieldLabel>{label}</FieldLabel>
|
|
<FieldActions>
|
|
{isEditing ? (
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsEditing(false)}
|
|
className={iconButtonClassName}
|
|
aria-label="Cancel edit"
|
|
>
|
|
<CloseIcon size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={confirm}
|
|
className={iconButtonClassName}
|
|
aria-label="Confirm edit"
|
|
>
|
|
<CheckIcon size={18} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={startEdit}
|
|
className={iconButtonClassName}
|
|
aria-label={`Edit ${label.toLowerCase()}`}
|
|
>
|
|
<EditIcon size={18} />
|
|
</button>
|
|
)}
|
|
</FieldActions>
|
|
</>
|
|
}
|
|
>
|
|
{isEditing ? (
|
|
<div className="relative mt-1 flex gap-2">
|
|
{multiline ? (
|
|
<textarea
|
|
value={draft}
|
|
onChange={e => setDraft(e.target.value)}
|
|
className="h-32 w-full grow rounded border border-border bg-surface-muted p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
|
|
rows={5}
|
|
/>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={draft}
|
|
onChange={e => setDraft(e.target.value)}
|
|
className="w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
|
|
/>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<FieldValue className={valueClassName}>{value || 'N/A'}</FieldValue>
|
|
)}
|
|
</Field>
|
|
);
|
|
}
|
|
|
|
export default function DocumentPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const queryClient = useQueryClient();
|
|
const { data: docData, isLoading: docLoading } = useGetDocument(id || '', {
|
|
query: { enabled: Boolean(id) },
|
|
});
|
|
const editMutation = useEditDocument();
|
|
const runWithToast = useToastMutation();
|
|
|
|
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
|
|
|
if (docLoading) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
const doc = docData?.document;
|
|
|
|
if (!doc) {
|
|
return <div className="text-content-muted">Document not found</div>;
|
|
}
|
|
|
|
const percentage = doc.percentage ?? 0;
|
|
const secondsPerPercent = doc.seconds_per_percent || 0;
|
|
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
|
|
|
const save = (data: EditDocumentBody): Promise<boolean> =>
|
|
runWithToast(() => editMutation.mutateAsync({ id: doc.id, data }), {
|
|
error: 'Failed to save',
|
|
onSuccess: response => queryClient.setQueryData(getGetDocumentQueryKey(doc.id), response),
|
|
});
|
|
|
|
return (
|
|
<div className="relative size-full">
|
|
<div className="size-full overflow-scroll rounded bg-surface p-4 text-content shadow-lg">
|
|
<div className="relative float-left mb-2 mr-4 flex w-44 flex-col gap-2 md:w-60 lg:w-80">
|
|
<img
|
|
className="w-full rounded object-fill"
|
|
src={`/api/v1/documents/${doc.id}/cover`}
|
|
alt={`${doc.title} cover`}
|
|
/>
|
|
|
|
{doc.filepath && (
|
|
<a
|
|
href={`/reader/${doc.id}`}
|
|
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500"
|
|
>
|
|
Read
|
|
</a>
|
|
)}
|
|
|
|
<div className="relative z-20 flex flex-wrap-reverse justify-between gap-2">
|
|
<div className="min-w-[50%] md:mr-2">
|
|
<div className="flex gap-1 text-sm">
|
|
<p className="text-content-muted">ISBN-10:</p>
|
|
<p className="font-medium">{doc.isbn10 || 'N/A'}</p>
|
|
</div>
|
|
<div className="flex gap-1 text-sm">
|
|
<p className="text-content-muted">ISBN-13:</p>
|
|
<p className="font-medium">{doc.isbn13 || 'N/A'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative my-auto flex grow justify-between text-content-muted">
|
|
<a
|
|
href={`/activity?document=${doc.id}`}
|
|
aria-label="Activity"
|
|
className={iconButtonClassName}
|
|
>
|
|
<ActivityIcon size={28} />
|
|
</a>
|
|
|
|
{doc.filepath ? (
|
|
<a
|
|
href={`/api/v1/documents/${doc.id}/file`}
|
|
aria-label="Download"
|
|
className={iconButtonClassName}
|
|
>
|
|
<DownloadIcon size={28} />
|
|
</a>
|
|
) : (
|
|
<span className="text-content-subtle">
|
|
<DownloadIcon size={28} disabled />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
|
<EditableField label="Title" value={doc.title} onSave={value => save({ title: value })} />
|
|
|
|
<EditableField
|
|
label="Author"
|
|
value={doc.author}
|
|
onSave={value => save({ author: value })}
|
|
/>
|
|
|
|
<Field
|
|
label={
|
|
<>
|
|
<FieldLabel>Time Read</FieldLabel>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTimeReadInfo(!showTimeReadInfo)}
|
|
className={`${iconButtonClassName} my-auto`}
|
|
aria-label="Show time read info"
|
|
>
|
|
<InfoIcon size={18} />
|
|
</button>
|
|
<div
|
|
className={`absolute right-0 top-7 z-30 rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200 ${
|
|
showTimeReadInfo ? 'opacity-100' : 'pointer-events-none opacity-0'
|
|
}`}
|
|
>
|
|
<div className="flex text-xs">
|
|
<p className="w-32 text-content-subtle">Seconds / Percent</p>
|
|
<p className="font-medium">
|
|
{secondsPerPercent !== 0 ? secondsPerPercent : 'N/A'}
|
|
</p>
|
|
</div>
|
|
<div className="flex text-xs">
|
|
<p className="w-32 text-content-subtle">Words / Minute</p>
|
|
<p className="font-medium">{doc.wpm && doc.wpm > 0 ? doc.wpm : 'N/A'}</p>
|
|
</div>
|
|
<div className="flex text-xs">
|
|
<p className="w-32 text-content-subtle">Est. Time Left</p>
|
|
<p className="whitespace-nowrap font-medium">
|
|
{totalTimeLeftSeconds > 0 ? formatDuration(totalTimeLeftSeconds) : 'N/A'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
}
|
|
>
|
|
<FieldValue>
|
|
{doc.total_time_seconds && doc.total_time_seconds > 0
|
|
? formatDuration(doc.total_time_seconds)
|
|
: 'N/A'}
|
|
</FieldValue>
|
|
</Field>
|
|
|
|
<Field label={<FieldLabel>Progress</FieldLabel>}>
|
|
<FieldValue>{`${percentage.toFixed(2)}%`}</FieldValue>
|
|
</Field>
|
|
</div>
|
|
|
|
<EditableField
|
|
label="Description"
|
|
value={doc.description || ''}
|
|
multiline
|
|
valueClassName="hyphens-auto text-justify"
|
|
onSave={value => save({ description: value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|