+134
-364
@@ -6,119 +6,158 @@ import {
|
||||
useEditDocument,
|
||||
getGetDocumentQueryKey,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
import { Document } from '../generated/model/document';
|
||||
import type { EditDocumentBody } from '../generated/model';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import {
|
||||
DeleteIcon,
|
||||
ActivityIcon,
|
||||
SearchIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
InfoIcon,
|
||||
CloseIcon,
|
||||
CheckIcon,
|
||||
} from '../icons';
|
||||
import { Field, FieldLabel, FieldValue, FieldActions } from '../components';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { ActivityIcon, DownloadIcon, EditIcon, InfoIcon, CloseIcon, CheckIcon } from '../icons';
|
||||
import { Field, FieldLabel, FieldValue, FieldActions, LoadingState } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
|
||||
const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-content';
|
||||
const popupClassName =
|
||||
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
|
||||
const popupInputClassName = 'rounded bg-surface p-2 text-content';
|
||||
const editInputClassName =
|
||||
'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500';
|
||||
|
||||
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-secondary-200 bg-secondary-50 p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500"
|
||||
rows={5}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
className={editInputClassName}
|
||||
/>
|
||||
)}
|
||||
</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 || '');
|
||||
const editMutation = useEditDocument();
|
||||
const { showError } = useToasts();
|
||||
|
||||
const [showEditCover, setShowEditCover] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showIdentify, setShowIdentify] = useState(false);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [isEditingAuthor, setIsEditingAuthor] = useState(false);
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [showTimeReadInfo, setShowTimeReadInfo] = useState(false);
|
||||
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editAuthor, setEditAuthor] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
|
||||
if (docLoading) {
|
||||
return <div className="text-content-muted">Loading...</div>;
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!docData || docData.status !== 200) {
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
}
|
||||
|
||||
const document = docData.data.document as Document;
|
||||
|
||||
if (!document) {
|
||||
return <div className="text-content-muted">Document not found</div>;
|
||||
}
|
||||
const document = docData.data.document;
|
||||
|
||||
const percentage = document.percentage ?? 0;
|
||||
const secondsPerPercent = document.seconds_per_percent || 0;
|
||||
const totalTimeLeftSeconds = Math.round((100 - percentage) * secondsPerPercent);
|
||||
|
||||
const startEditing = (field: 'title' | 'author' | 'description') => {
|
||||
if (field === 'title') setEditTitle(document.title);
|
||||
if (field === 'author') setEditAuthor(document.author);
|
||||
if (field === 'description') setEditDescription(document.description || '');
|
||||
};
|
||||
|
||||
const saveTitle = () => {
|
||||
editMutation.mutate(
|
||||
{ id: document.id, data: { title: editTitle } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingTitle(false);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingTitle(false),
|
||||
const save = async (data: EditDocumentBody): Promise<boolean> => {
|
||||
try {
|
||||
const response = await editMutation.mutateAsync({ id: document.id, data });
|
||||
if (response.status !== 200) {
|
||||
showError('Failed to save: ' + getErrorMessage(response.data));
|
||||
return false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const saveAuthor = () => {
|
||||
editMutation.mutate(
|
||||
{ id: document.id, data: { author: editAuthor } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingAuthor(false);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingAuthor(false),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const saveDescription = () => {
|
||||
editMutation.mutate(
|
||||
{ id: document.id, data: { description: editDescription } },
|
||||
{
|
||||
onSuccess: response => {
|
||||
setIsEditingDescription(false);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
},
|
||||
onError: () => setIsEditingDescription(false),
|
||||
}
|
||||
);
|
||||
queryClient.setQueryData(getGetDocumentQueryKey(document.id), response);
|
||||
return true;
|
||||
} catch (err) {
|
||||
showError('Failed to save: ' + getErrorMessage(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<label className="z-10 cursor-pointer" htmlFor="edit-cover-checkbox">
|
||||
<img
|
||||
className="w-full rounded object-fill"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
/>
|
||||
</label>
|
||||
<img
|
||||
className="w-full rounded object-fill"
|
||||
src={`/api/v1/documents/${document.id}/cover`}
|
||||
alt={`${document.title} cover`}
|
||||
/>
|
||||
|
||||
{document.filepath && (
|
||||
<a
|
||||
@@ -141,77 +180,7 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit-cover-checkbox"
|
||||
className="hidden"
|
||||
checked={showEditCover}
|
||||
onChange={e => setShowEditCover(e.target.checked)}
|
||||
/>
|
||||
<div
|
||||
className={`absolute left-0 top-0 z-30 flex flex-col gap-2 ${popupClassName} ${
|
||||
showEditCover ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="flex w-72 flex-col gap-2 text-sm">
|
||||
<input
|
||||
type="file"
|
||||
id="cover_file"
|
||||
name="cover_file"
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Upload Cover
|
||||
</button>
|
||||
</form>
|
||||
<form className="flex w-72 flex-col gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
id="remove_cover"
|
||||
name="remove_cover"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Remove Cover
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative my-auto flex grow justify-between text-content-muted">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDelete(!showDelete)}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<DeleteIcon size={28} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
|
||||
showDelete ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="w-24 text-sm">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-red-600 px-2 py-1 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/activity?document=${document.id}`}
|
||||
aria-label="Activity"
|
||||
@@ -220,55 +189,6 @@ export default function DocumentPage() {
|
||||
<ActivityIcon size={28} />
|
||||
</a>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowIdentify(!showIdentify)}
|
||||
aria-label="Identify"
|
||||
className={iconButtonClassName}
|
||||
>
|
||||
<SearchIcon size={28} />
|
||||
</button>
|
||||
<div
|
||||
className={`absolute bottom-7 left-5 z-30 ${popupClassName} ${
|
||||
showIdentify ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
>
|
||||
<form className="flex flex-col gap-2 text-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
placeholder="Title"
|
||||
defaultValue={document.title}
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="author"
|
||||
name="author"
|
||||
placeholder="Author"
|
||||
defaultValue={document.author}
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="isbn"
|
||||
name="isbn"
|
||||
placeholder="ISBN 10 / ISBN 13"
|
||||
defaultValue={document.isbn13 || document.isbn10}
|
||||
className={popupInputClassName}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-secondary-700 px-2 py-1 text-sm font-medium text-white hover:bg-secondary-800 dark:bg-secondary-600"
|
||||
>
|
||||
Identify
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{document.filepath ? (
|
||||
<a
|
||||
href={`/api/v1/documents/${document.id}/file`}
|
||||
@@ -287,117 +207,17 @@ export default function DocumentPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid justify-between gap-4 pb-4 sm:grid-cols-2">
|
||||
<Field
|
||||
isEditing={isEditingTitle}
|
||||
label={
|
||||
<>
|
||||
<FieldLabel>Title</FieldLabel>
|
||||
<FieldActions>
|
||||
{isEditingTitle ? (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingTitle(false)}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveTitle}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Confirm edit"
|
||||
>
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
startEditing('title');
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit title"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</FieldActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEditingTitle ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className={editInputClassName}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue>{document.title}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField
|
||||
label="Title"
|
||||
value={document.title}
|
||||
onSave={value => save({ title: value })}
|
||||
/>
|
||||
|
||||
<Field
|
||||
isEditing={isEditingAuthor}
|
||||
label={
|
||||
<>
|
||||
<FieldLabel>Author</FieldLabel>
|
||||
<FieldActions>
|
||||
{isEditingAuthor ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingAuthor(false)}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveAuthor}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Confirm edit"
|
||||
>
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
startEditing('author');
|
||||
setIsEditingAuthor(true);
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit author"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</FieldActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEditingAuthor ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editAuthor}
|
||||
onChange={e => setEditAuthor(e.target.value)}
|
||||
className={editInputClassName}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue>{document.author}</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField
|
||||
label="Author"
|
||||
value={document.author}
|
||||
onSave={value => save({ author: value })}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label={
|
||||
@@ -450,63 +270,13 @@ export default function DocumentPage() {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
isEditing={isEditingDescription}
|
||||
label={
|
||||
<>
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<FieldActions>
|
||||
{isEditingDescription ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingDescription(false)}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Cancel edit"
|
||||
>
|
||||
<CloseIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveDescription}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Confirm edit"
|
||||
>
|
||||
<CheckIcon size={18} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
startEditing('description');
|
||||
setIsEditingDescription(true);
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
aria-label="Edit description"
|
||||
>
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
)}
|
||||
</FieldActions>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isEditingDescription ? (
|
||||
<div className="relative mt-1 flex gap-2">
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
className="h-32 w-full grow rounded border border-secondary-200 bg-secondary-50 p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FieldValue className="hyphens-auto text-justify">
|
||||
{document.description || 'N/A'}
|
||||
</FieldValue>
|
||||
)}
|
||||
</Field>
|
||||
<EditableField
|
||||
label="Description"
|
||||
value={document.description || ''}
|
||||
multiline
|
||||
valueClassName="hyphens-auto text-justify"
|
||||
onSave={value => save({ description: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user