322 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|