n39librarian/frontend/src/shared/components/modals/BookModal.tsx

322 lines
10 KiB
TypeScript

import React, { useCallback, useEffect, useState } from "react";
import { Button, Col, Form, Modal, Row } from "react-bootstrap";
import { ImBook, ImCamera, ImCancelCircle } from "react-icons/im";
import { ModalHeader } from "./ModalHeader";
import { useForm, useWatch } from "react-hook-form";
import { Book } from "../../../types/Book";
import { useScanner } from "../../utils/useScanner";
import { BookModalProps } from "./types";
import { tryGoogleBooksApi } from "../../../pages/Main/utils/tryGoogleBooksApi";
import { tryDeutscheNationalBibliothekApi } from "../../../pages/Main/utils/tryDeutscheNationalBibliothekApi";
import { useQuery } from "@tanstack/react-query";
type BookForm = Pick<Book, "isbn" | "title" | "shelf" | "published">;
export const BookModal = ({
open,
onClose,
book,
}: Omit<BookModalProps, "type">): React.JSX.Element => {
const isEdit = !!book;
const [showScanner, setShowScanner] = useState(false);
const [addNew, setAddNew] = useState<boolean>(false);
const { data: shelves, isLoading } = useQuery<string[]>({
queryFn: async () => {
const response = await fetch(`/api/shelves/list`);
const data = await response.text();
return data
.split(",")
.map((s) => s.replaceAll('"', "").replace("[", "").replace("]", ""));
},
queryKey: ["shleves"],
});
const { control, register, formState, setError, setValue, reset } =
useForm<BookForm>({
mode: "onChange",
defaultValues: book,
});
useEffect(() => {
reset(book);
}, [book, reset]);
const [processingDetection, setProcessingDetection] = useState(false);
const onDetected = useCallback(
async (result: string) => {
if (!processingDetection) {
setProcessingDetection(true);
const apiResponses = (
await Promise.all([
tryDeutscheNationalBibliothekApi(result),
tryGoogleBooksApi(result),
])
).filter((b) => !!b) as Pick<Book, "published" | "title">[];
if (apiResponses.length) {
Object.entries(apiResponses[0]).forEach(([key, value]) => {
setValue(key as keyof BookForm, value);
});
}
setValue("isbn", result);
setShowScanner(false);
setProcessingDetection(false);
}
},
[processingDetection, setValue]
);
const { scannerError, setScannerRef } = useScanner({
onDetected,
});
const values = useWatch({ control });
const [submitError, setSubmitError] = useState<string | undefined>();
const onSubmit = useCallback(
async (data: Partial<BookForm>) => {
setSubmitError(undefined);
let error = false;
if (!data.isbn?.length) {
setError("isbn", { message: "ISBN is required" });
error = true;
}
if (!data.title?.length) {
setError("title", { message: "Title is required" });
error = true;
}
if (!data.shelf?.length) {
setError("shelf", { message: "Shelf is required" });
error = true;
}
if (!data.published) {
setError("published", { message: "Year published is required" });
error = true;
}
if (error) return;
const createdBook = await fetch(
isEdit ? "/api/books/edit" : "/api/books/create",
{
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
}
);
if (!createdBook.ok) {
setSubmitError(await createdBook.text());
} else {
onClose();
}
},
[isEdit, onClose, setError]
);
return (
<Modal show={open} onHide={onClose} centered>
<ModalHeader
onClose={onClose}
title={`${!!book ? "Edit" : "Add new"} Book`}
icon={<ImBook size={50} className='ml-0 mr-auto' />}
/>
<Modal.Body
style={{
borderTop: "none",
}}
>
{!showScanner && (
<Form
className='mb-2'
onSubmit={(ev) => {
ev.preventDefault();
onSubmit(values);
}}
>
<Form.Group as={Row} className='mb-2'>
<Col sm='2'>
<Form.Label>ISBN</Form.Label>
</Col>
<Col className='d-flex flex-column'>
<div className='d-flex'>
<Form.Control
{...register("isbn", { required: true, maxLength: 360 })}
isInvalid={!!formState.errors.isbn}
style={{
borderRadius: "5px 0px 0px 5px",
}}
/>
<Button
style={{
borderRadius: "0px 5px 5px 0px",
}}
className='mr-2 pt-0'
disabled={showScanner}
onClick={() => setShowScanner(true)}
>
<ImCamera size={20} />
</Button>
</div>
<Form.Control.Feedback
style={{ display: !formState.errors.isbn ? "none" : "block" }}
type='invalid'
>
{!formState.errors.isbn
? "ISBN is required"
: formState.errors.isbn?.message}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group as={Row} className='mb-2'>
<Col sm='2'>
<Form.Label>Title</Form.Label>
</Col>
<Col>
<Form.Control
{...register("title", { required: true, maxLength: 360 })}
isInvalid={!!formState.errors.title}
/>
<Form.Control.Feedback type='invalid'>
{!values.title
? "Title is required"
: formState.errors.title?.message}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group as={Row} className='mb-2'>
<Col sm='2'>
<Form.Label>Year published</Form.Label>
</Col>
<Col>
<Form.Control
{...register("published", {
required: true,
maxLength: 360,
validate: {
isReasonable: (value) => {
if (value > 1900 && value < 2150) {
return true;
}
return `${value} is not a reasonable year`;
},
isNumber: (value) => {
if (value && value.toString().match(/^[0-9\b]+$/)) {
return true;
}
return "Please enter a valid year.";
},
},
})}
isInvalid={!!formState.errors.published}
/>
<Form.Control.Feedback type='invalid'>
{!values.published
? "Year published is required"
: formState.errors.published?.message}
</Form.Control.Feedback>
</Col>
</Form.Group>
<Form.Group as={Row} className='mb-2'>
<Col sm='2'>
<Form.Label>Shelf</Form.Label>
</Col>
<Col>
{!isLoading &&
(addNew ? (
<div className='d-flex'>
<Form.Control
id='move-shelf-text-input'
{...register("shelf", { required: true })}
style={{
borderRadius: "5px 0px 0px 5px",
}}
/>
<Button
style={{
borderRadius: "0px 5px 5px 0px",
}}
className='d-flex mr-2 pt-2'
onClick={() => setAddNew(false)}
>
<div className='m-auto'>
<ImCancelCircle size={20} />
</div>
</Button>
</div>
) : (
<Form.Select
id='move-shelf-select'
{...register("shelf", {
required: true,
onChange: (ev) => {
if (ev.target.value === "addNew") {
setAddNew(true);
}
},
})}
isInvalid={!!formState.errors.shelf}
>
{shelves?.map((shelf) => (
<option key='key' value={shelf}>
{shelf}
</option>
))}
<option value='addNew'>Add new</option>
</Form.Select>
))}
<Form.Control.Feedback type='invalid'>
{!values.shelf
? "Target shelf is required"
: formState.errors.shelf?.message}
</Form.Control.Feedback>
</Col>
</Form.Group>
<div className='d-flex mx-auto mb-auto mt-2 w-100'>
<Button
style={{
borderRadius: "5px",
marginLeft: "auto",
marginRight: "10px",
}}
onClick={() => onSubmit(values)}
>
{isEdit ? "Submit" : "Add Book"}
</Button>
</div>
</Form>
)}
{showScanner && (
<div
className='w-100 overflow-hidden'
ref={(ref) => setScannerRef(ref)}
style={{ position: "relative", height: "25vh" }}
>
<canvas
className='drawingBuffer w-100 position-absolute'
style={{
height: "100%",
}}
/>
</div>
)}
{scannerError ? (
<p>
ERROR INITIALIZING CAMERA ${JSON.stringify(scannerError)} -- DO YOU
HAVE PERMISSION?
</p>
) : null}
{!!submitError && (
<Form.Control.Feedback style={{ display: "block" }} type='invalid'>
{submitError}
</Form.Control.Feedback>
)}
</Modal.Body>
</Modal>
);
};