Files
AnthoLume/frontend/src/pages/DocumentPage.tsx
T
evan 09daf6d511
continuous-integration/drone/pr Build is failing
cleanup api
2026-07-03 20:48:29 -04:00

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>
);
}