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